diff --git a/.aptly.conf b/.aptly.conf index cbea3aee1..deb53174e 100644 --- a/.aptly.conf +++ b/.aptly.conf @@ -25,7 +25,7 @@ "region": "eu01", "bucket": "distribution", "acl":"public-read", - "endpoint": "object.storage.eu01.onstackit.cloud" + "endpoint": "https://object.storage.eu01.onstackit.cloud" } }, "SwiftPublishEndpoints": {}, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index eab47aae8..7ec96a1b5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @marceljk @bahkauv70 @Fyusel @rubenhoenle \ No newline at end of file +* @stackitcloud/developer-tools \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5400a08cb..3a4e09fe6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,12 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 7 + exclude: ["github.com/stackitcloud*"] - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" + cooldown: + default-days: 7 diff --git a/.github/docs/contribution-guide/client.go b/.github/docs/contribution-guide/client.go new file mode 100644 index 000000000..df0f74442 --- /dev/null +++ b/.github/docs/contribution-guide/client.go @@ -0,0 +1,13 @@ +package client + +import ( + "github.com/spf13/viper" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-sdk-go/services/foo" + // (...) +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*foo.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.fooCustomEndpointKey), false, genericclient.CreateApiClient[*foo.APIClient](foo.NewAPIClient)) +} diff --git a/.github/docs/contribution-guide/cmd.go b/.github/docs/contribution-guide/cmd.go new file mode 100644 index 000000000..d9184fb00 --- /dev/null +++ b/.github/docs/contribution-guide/cmd.go @@ -0,0 +1,134 @@ +package bar + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + // (...) +) + +// Define consts for command flags +const ( + someArg = "MY_ARG" + someFlag = "my-flag" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + MyArg string + MyFlag *string +} + +// "bar" command constructor +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "bar", + Short: "Short description of the command (is shown in the help of parent command)", + Long: "Long description of the command. Can contain some more information about the command usage. It is shown in the help of the current command.", + Args: args.SingleArg(someArg, utils.ValidateUUID), // Validate argument, with an optional validation function + Example: examples.Build( + examples.NewExample( + `Do something with command "bar"`, + "$ stackit foo bar arg-value --my-flag flag-value"), + //... + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("(...): %w", err) + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + // Check API response "resp" and output accordingly + if resp.Item == nil { + params.Printer.Info("(...)", projectLabel) + return nil + } + return outputResult(params.Printer, cmd, model.OutputFormat, instances) + }, + } + + configureFlags(cmd) + return cmd +} + +// Configure command flags (type, default value, and description) +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(someFlag, "shorthand", "defaultValue", "My flag description") +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + myArg := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + MyArg: myArg, + MyFlag: flags.FlagToStringPointer(p, cmd, someFlag), + } + + // Write the input model to the debug logs + p.DebugInputModel(model) + return &model, nil +} + +// Build request to the API +func buildRequest(ctx context.Context, model *inputModel, apiClient *foo.APIClient) foo.ApiListInstancesRequest { + req := apiClient.GetBar(ctx, model.ProjectId, model.MyArg, someArg) + return req +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, cmd *cobra.Command, outputFormat string, resources []foo.Resource) error { + // the output result handles JSON/YAML output, you can pass your own callback func for pretty (default) output format + return p.OutputResult(outputFormat, resources, func() error { + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATE") + for i := range resources { + resource := resources[i] + table.AddRow(*resource.ResourceId, *resource.Name, *resource.State) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index 31621a1ba..000000000 --- a/.github/renovate.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended"], - "prHourlyLimit": 10, - "labels": ["renovate"], - "repositories": ["stackitcloud/stackit-cli"], - "enabledManagers": ["gomod", "github-actions"], - "packageRules": [ - { - "matchSourceUrls": ["https://github.com/stackitcloud/stackit-sdk-go"], - "groupName": "STACKIT SDK modules" - } - ], - "postUpdateOptions": ["gomodTidy", "gomodUpdateImportPaths"] -} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 66c094cd5..3a5bb9f13 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,15 @@ name: CI -on: [pull_request, workflow_dispatch] +on: + pull_request: + workflow_dispatch: + push: + branches: + - main + +env: + CODE_COVERAGE_FILE_NAME: "coverage.out" # must be the same as in Makefile + CODE_COVERAGE_ARTIFACT_NAME: "code-coverage" jobs: main: @@ -8,10 +17,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: "go.mod" cache: true @@ -25,3 +34,39 @@ jobs: - name: Test run: make test + + - name: Archive code coverage results + uses: actions/upload-artifact@v7 + with: + name: ${{ env.CODE_COVERAGE_ARTIFACT_NAME }} + path: ${{ env.CODE_COVERAGE_FILE_NAME }} + + config: + name: Check GoReleaser config + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + args: check + + code_coverage: + name: "Code coverage report" + if: github.event_name == 'pull_request' # Do not run when workflow is triggered by push to main branch + runs-on: ubuntu-latest + needs: main + permissions: + contents: read + actions: read # to download code coverage results from "main" job + pull-requests: write # write permission needed to comment on PR + steps: + - name: Check new code coverage + uses: fgrosse/go-coverage-report@v1.3.0 + continue-on-error: true # Add this line to prevent pipeline failures in forks + with: + coverage-artifact-name: ${{ env.CODE_COVERAGE_ARTIFACT_NAME }} + coverage-file-name: ${{ env.CODE_COVERAGE_FILE_NAME }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0bddeed42..2f24d7955 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,26 +21,35 @@ jobs: runs-on: macOS-latest env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }} - # Needed to publish new packages to our S3-hosted APT repo - AWS_ACCESS_KEY_ID: ${{ secrets.OBJECT_STORAGE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SECRET_ACCESS_KEY }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: # Allow goreleaser to access older tag information. fetch-depth: 0 + - name: Install go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: "go.mod" cache: true + - name: Import GPG key - uses: crazy-max/ghaction-import-gpg@v6 + uses: crazy-max/ghaction-import-gpg@v7 id: import_gpg with: gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} passphrase: ${{ secrets.GPG_PASSPHRASE }} + + # nfpm-rpm signing needs gpg provided as filepath + # https://goreleaser.com/customization/nfpm/ + - name: Create GPG key file + run: | + KEY_PATH="$RUNNER_TEMP/gpg-private-key.asc" + printf '%s' "${{ secrets.GPG_PRIVATE_KEY }}" > "$KEY_PATH" + chmod 600 "$KEY_PATH" + echo "GPG_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV" + - name: Set up keychain run: | echo -n $SIGNING_CERTIFICATE_BASE64 | base64 -d -o ./ApplicationID.p12 @@ -48,6 +57,8 @@ jobs: security create-keychain -p "${{ secrets.TEMP_KEYCHAIN }}" $KEYCHAIN_PATH security default-keychain -s $KEYCHAIN_PATH security unlock-keychain -p "${{ secrets.TEMP_KEYCHAIN }}" $KEYCHAIN_PATH + # the keychain gets locked automatically after 300s, so we have to extend this interval to e.g. 900 seconds + security set-keychain-settings -lut 900 security import ./ApplicationID.p12 -P "${{ secrets.APPLICATION_ID }}" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH echo -n $AUTHKEY_BASE64 | base64 -d -o ./AuthKey.p8 @@ -59,23 +70,103 @@ jobs: APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }} SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLICATION_ID_CERT }} AUTHKEY_BASE64: ${{ secrets.APPLE_API_KEY }} - # aptly version 1.6.0 results in an segmentation fault. Therefore we fall back to version 1.5.0. - # Since it is not possible to specify a version via brew command a formula was added for aptly 1.5.0 - # (source: https://github.com/Homebrew/homebrew-core/pull/202415/files) - - name: Install Aptly version 1.5.0 - run: brew install aptly.rb - name: Install Snapcraft uses: samuelmeuli/action-snapcraft@v3 + - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: args: release --clean env: GITHUB_TOKEN: ${{ secrets.CLI_RELEASE }} GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} + GPG_KEY_PATH: ${{ env.GPG_KEY_PATH }} + # nfpm-rpm signing needs this env to be set. + NFPM_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + + - name: Clean up GPG key file + if: always() + run: | + rm -f "$GPG_KEY_PATH" + + - name: Upload artifacts to workflow + uses: actions/upload-artifact@v7 + with: + name: goreleaser-dist-temp + path: dist + retention-days: 1 + + publish-apt: + name: Publish APT + runs-on: macOS-latest + needs: [goreleaser] + env: + # Needed to publish new packages to our S3-hosted APT repo + AWS_ACCESS_KEY_ID: ${{ secrets.OBJECT_STORAGE_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SECRET_ACCESS_KEY }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + # use the artifacts from the "goreleaser" job + - name: Download artifacts from workflow + uses: actions/download-artifact@v8 + with: + name: goreleaser-dist-temp + path: dist + + - name: Install Aptly + run: brew install aptly + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v7 + id: import_gpg + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + - name: Publish packages to APT repo if: contains(github.ref_name, '-') == false env: GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} GPG_PRIVATE_KEY_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} run: ./scripts/publish-apt-packages.sh + + publish-rpm: + name: Publish RPM + runs-on: ubuntu-latest + needs: [goreleaser] + env: + # Needed to publish new packages to our S3-hosted RPM repo + AWS_ACCESS_KEY_ID: ${{ secrets.OBJECT_STORAGE_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.OBJECT_STORAGE_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: eu01 + AWS_ENDPOINT_URL: https://object.storage.eu01.onstackit.cloud + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download artifacts from workflow + uses: actions/download-artifact@v8 + with: + name: goreleaser-dist-temp + path: dist + + - name: Install RPM tools + run: | + sudo apt-get update + sudo apt-get install -y createrepo-c + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v7 + id: import_gpg + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + + - name: Publish RPM packages + if: contains(github.ref_name, '-') == false + env: + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_PRIVATE_KEY_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} + run: ./scripts/publish-rpm-packages.sh \ No newline at end of file diff --git a/.github/workflows/renovate.yaml b/.github/workflows/renovate.yaml deleted file mode 100644 index 952f88e2f..000000000 --- a/.github/workflows/renovate.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: Renovate - -on: - schedule: - - cron: "0 0 * * *" - workflow_dispatch: - -jobs: - renovate: - name: Renovate - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Self-hosted Renovate - uses: renovatebot/github-action@v42.0.1 - with: - configurationFile: .github/renovate.json - token: ${{ secrets.RENOVATE_TOKEN }} diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 3ee54f0da..894da7025 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -11,6 +11,7 @@ on: env: DAYS_BEFORE_PR_STALE: 7 DAYS_BEFORE_PR_CLOSE: 7 + EXEMPT_PR_LABELS: "ignore-stale" permissions: issues: write @@ -23,13 +24,14 @@ jobs: timeout-minutes: 10 steps: - name: "Mark old PRs as stale" - uses: actions/stale@v9 + uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-pr-message: "This PR was marked as stale after ${{ env.DAYS_BEFORE_PR_STALE }} days of inactivity and will be closed after another ${{ env.DAYS_BEFORE_PR_CLOSE }} days of further inactivity. If this PR should be kept open, just add a comment, remove the stale label or push new commits to it." close-pr-message: "This PR was closed automatically because it has been stalled for ${{ env.DAYS_BEFORE_PR_CLOSE }} days with no activity. Feel free to re-open it at any time." days-before-pr-stale: ${{ env.DAYS_BEFORE_PR_STALE }} days-before-pr-close: ${{ env.DAYS_BEFORE_PR_CLOSE }} + exempt-pr-labels: ${{ env.EXEMPT_PR_LABELS }} # never mark issues as stale or close them days-before-issue-stale: -1 days-before-issue-close: -1 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6e766acc7..7fb43c81d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -32,23 +32,52 @@ builds: - amd64 hooks: post: - - | - sh -c ' - codesign -s "{{.Env.APPLE_APPLICATION_IDENTITY}}" -f -v --options=runtime "dist/macos-builds_{{.Target}}/{{.Name}}" - codesign -vvv --deep --strict "dist/macos-builds_{{.Target}}/{{.Name}}" - ls -l "dist/macos_{{.Target}}" - hdiutil create -volname "STACKIT-CLI" -srcfolder "dist/macos-builds_{{.Target}}/{{.Name}}" -ov -format UDZO "dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg" - codesign -s "{{.Env.APPLE_APPLICATION_IDENTITY}}" -f -v --options=runtime "dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg" - xcrun notarytool submit --keychain-profile "stackit-cli" --wait --progress dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg - xcrun stapler staple "dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg" - spctl -a -t open --context context:primary-signature -v dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg - ' + # Signing + - cmd: codesign -s "{{.Env.APPLE_APPLICATION_IDENTITY}}" -f -v --options=runtime "dist/macos-builds_{{.Target}}/{{.Name}}" + output: true + - cmd: codesign -vvv --deep --strict "dist/macos-builds_{{.Target}}/{{.Name}}" + output: true + - cmd: hdiutil create -volname "STACKIT-CLI" -srcfolder "dist/macos-builds_{{.Target}}/{{.Name}}" -ov -format UDZO "dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg" + output: true + - cmd: codesign -s "{{.Env.APPLE_APPLICATION_IDENTITY}}" -f -v --options=runtime "dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg" + output: true + - cmd: xcrun notarytool submit --keychain-profile "stackit-cli" --wait --progress dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg + output: true + - cmd: xcrun stapler staple "dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg" + output: true + - cmd: spctl -a -t open --context context:primary-signature -v dist/{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}.dmg + output: true + # Completion files + - cmd: mkdir -p dist/completions + - cmd: sh -c 'go run main.go completion zsh > ./dist/completions/stackit.zsh' + - cmd: sh -c 'go run main.go completion bash > ./dist/completions/stackit.bash' + - cmd: sh -c 'go run main.go completion fish > ./dist/completions/stackit.fish' + + - id: freebsd-builds + env: + - CGO_ENABLED=0 + goos: + - freebsd + goarch: + - arm64 + - amd64 + binary: "stackit" archives: - - format: tar.gz - format_overrides: - - goos: windows - format: zip + - id: windows-archives + ids: + - windows-builds + formats: [ 'zip' ] + - ids: + - linux-builds + - macos-builds + - freebsd-builds + formats: [ 'tar.gz' ] + files: + - src: ./dist/completions/* + dst: completions + - LICENSE.md + - README.md release: # If set to auto, the GitHub release will be marked as "Pre-release" @@ -66,7 +95,7 @@ changelog: nfpms: - id: linux-packages # IDs of the builds for which to create packages for - builds: + ids: - linux-builds package_name: stackit vendor: STACKIT @@ -76,25 +105,21 @@ nfpms: license: Apache 2.0 contents: - src: LICENSE.md - dst: LICENSE.md + dst: /usr/share/doc/stackit/copyright + file_info: + mode: 0644 formats: - deb - rpm -signs: - - artifacts: package - args: - [ - "-u", - "{{ .Env.GPG_FINGERPRINT }}", - "--output", - "${signature}", - "--detach-sign", - "${artifact}", - ] + rpm: + # The package is signed if a key_file is set + signature: + key_file: "{{ .Env.GPG_KEY_PATH }}" -brews: +homebrew_casks: - name: stackit + directory: Casks repository: owner: stackitcloud name: homebrew-tap @@ -102,19 +127,19 @@ brews: name: CLI Release Bot email: noreply@stackit.de homepage: "https://github.com/stackitcloud/stackit-cli" - description: "A command-line interface to manage STACKIT resources.\nThis CLI is in a beta state. More services and functionality will be supported soon." - directory: Formula + 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) skip_upload: auto - install: | - bin.install "stackit" - generate_completions_from_executable(bin/"stackit", "completion") + completions: + zsh: ./completions/stackit.zsh + bash: ./completions/stackit.bash + fish: ./completions/stackit.fish snapcrafts: # IDs of the builds for which to create packages for - - builds: + - ids: - linux-builds # The name of the snap name: stackit @@ -122,12 +147,12 @@ snapcrafts: # centre graphical frontends title: STACKIT CLI summary: A command-line interface to manage STACKIT resources. - description: "A command-line interface to manage STACKIT resources.\nThis CLI is in a beta state. More services and functionality will be supported soon." + description: "A command-line interface to manage STACKIT resources." license: Apache-2.0 confinement: classic # Grade "devel" will only release to `edge` and `beta` channels # Grade "stable" will also release to the `candidate` and `stable` channels - grade: devel + grade: stable # Whether to publish the Snap to the store publish: true @@ -151,4 +176,4 @@ winget: base: owner: microsoft name: winget-pkgs - branch: master \ No newline at end of file + branch: master diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md index 1ec7ea3ba..7da16657f 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -4,7 +4,7 @@ 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. +You can use a [service account](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/) 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 [STACKIT Terraform Provider](https://github.com/stackitcloud/terraform-provider-stackit), so if you have already set up your environment for those tools, you can just run: ```bash @@ -47,14 +47,14 @@ To use the key flow, you need to have a service account key, which must have an 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, additionally 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). +**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, additionally to the service account key. Check the STACKIT Docs for an [example of how to create your own key-pair](https://docs.stackit.cloud/platform/access-and-identity/service-accounts/how-tos/manage-service-account-keys/). 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](https://portal.stackit.cloud/): 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) +- As an alternative, use the [STACKIT Portal](https://portal.stackit.cloud/): 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/platform/access-and-identity/service-accounts/how-tos/manage-service-account-keys/) 2. Save the content of the service account key by copying it and saving it in a JSON file. @@ -92,7 +92,12 @@ The expected format of the service account key is a **json** with the following > - 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. +4. Alternative, if you want to pass the keys directly without storing a file on disk: + + - setting the environment variable `STACKIT_SERVICE_ACCOUNT_KEY` with the content of the service account key + - optional: setting the environment variable `STACKIT_PRIVATE_KEY` with the content of the private key + +5. 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 diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index a73808087..47d79abda 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -53,153 +53,16 @@ Please remember to run `make generate-docs` after your changes to keep the comma Below is a typical structure of a CLI command: -```go -package bar - -import ( - (...) -) - -// Define consts for command flags -const ( - someArg = "MY_ARG" - someFlag = "my-flag" -) - -// Struct to model user input (arguments and/or flags) -type inputModel struct { - *globalflags.GlobalFlagModel - MyArg string - MyFlag *string -} - -// "bar" command constructor -func NewCmd(p *print.Printer) *cobra.Command { - cmd := &cobra.Command{ - Use: "bar", - Short: "Short description of the command (is shown in the help of parent command)", - Long: "Long description of the command. Can contain some more information about the command usage. It is shown in the help of the current command.", - Args: args.SingleArg(someArg, utils.ValidateUUID), // Validate argument, with an optional validation function - Example: examples.Build( - examples.NewExample( - `Do something with command "bar"`, - "$ stackit foo bar arg-value --my-flag flag-value"), - ... - ), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - model, err := parseInput(p, cmd, args) - if err != nil { - return err - } - - // Configure API client - apiClient, err := client.ConfigureClient(p, cmd) - if err != nil { - return err - } - - // Call API - req := buildRequest(ctx, model, apiClient) - resp, err := req.Execute() - if err != nil { - return fmt.Errorf("(...): %w", err) - } - - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - projectLabel = model.ProjectId - } - - // Check API response "resp" and output accordingly - if resp.Item == nil { - p.Info("(...)", projectLabel) - return nil - } - return outputResult(p, cmd, model.OutputFormat, instances) - }, - } - - configureFlags(cmd) - return cmd -} - -// Configure command flags (type, default value, and description) -func configureFlags(cmd *cobra.Command) { - cmd.Flags().StringP(myFlag, "defaultValue", "My flag description") -} - -// Parse user input (arguments and/or flags) -func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { - myArg := inputArgs[0] - - globalFlags := globalflags.Parse(cmd) - if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} - } - - model := inputModel{ - GlobalFlagModel: globalFlags, - MyArg myArg, - MyFlag: flags.FlagToStringPointer(cmd, myFlag), - }, nil - - // Write the input model to the debug logs - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - - return &model, nil -} - -// Build request to the API -func buildRequest(ctx context.Context, model *inputModel, apiClient *foo.APIClient) foo.ApiListInstancesRequest { - req := apiClient.GetBar(ctx, model.ProjectId, model.MyArg, someParam) - return req -} - -// Output result based on the configured output format -func outputResult(p *print.Printer, cmd *cobra.Command, outputFormat string, resources []foo.Resource) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resources, "", " ") - if err != nil { - return fmt.Errorf("marshal resource list: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.Marshal(resources) - if err != nil { - return fmt.Errorf("marshal resource list: %w", err) - } - p.Outputln(string(details)) - return nil - default: - table := tables.NewTable() - table.SetHeader("ID", "NAME", "STATE") - for i := range resources { - resource := resources[i] - table.AddRow(*resource.ResourceId, *resource.Name, *resource.State) - } - err := table.Display(cmd) - if err != nil { - return fmt.Errorf("render table: %w", err) - } - return nil - } -} -``` +https://github.com/stackitcloud/stackit-cli/blob/main/.github/docs/contribution-guide/cmd.go Please remember to always add unit tests for `parseInput`, `buildRequest` (in `bar_test.go`), and any other util functions used. If the new command `bar` is the first command in the CLI using a STACKIT service `foo`, please refer to [Onboarding a new STACKIT service](./CONTRIBUTION.md/#onboarding-a-new-stackit-service). +You may also have to register the `bar` command as a new sub-command: + +https://github.com/stackitcloud/stackit-cli/blob/a5438f4cac3a794cb95d04891a83252aa9ae1f1e/internal/cmd/root.go#L162-L195 + #### Outputs, prints and debug logs The CLI has 4 different verbosity levels: @@ -224,39 +87,7 @@ If you want to add a command that uses a STACKIT service `foo` that was not yet 1. This is done in `internal/pkg/services/foo/client/client.go` 2. Below is an example of a typical `client.go` file structure: - ```go - package client - - import ( - (...) - "github.com/stackitcloud/stackit-sdk-go/services/foo" - ) - - func ConfigureClient(cmd *cobra.Command) (*foo.APIClient, error) { - var err error - var apiClient foo.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")) // Configuring region is needed if "foo" is a regional API - - customEndpoint := viper.GetString(config.fooCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - apiClient, err = foo.NewAPIClient(cfgOptions...) - if err != nil { - return nil, &errors.AuthError{} - } - - return apiClient, nil - } - ``` +https://github.com/stackitcloud/stackit-cli/blob/main/.github/docs/contribution-guide/client.go ### Local development diff --git a/INSTALLATION.md b/INSTALLATION.md index 72d06b8ec..69756bf2d 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -17,7 +17,47 @@ brew tap stackitcloud/tap 2. You can then install the CLI via: ```shell -brew install stackit +brew install --cask stackit +``` + +#### Formula deprecated + +The homebrew formula is deprecated, will no longer be updated and will be removed after 2026-01-22. +You need to install the STACKIT CLI as cask. +Therefor you need to uninstall the formula and reinstall it as cask. + +Your profiles should normally remain. To ensure that nothing will be gone, you should backup them. + +1. Export your existing profiles. This will create a json file in your current directory. +```shell +stackit config profile export default +``` + +2. If you have multiple profiles, then execute the export command for each of them. You can find your profiles via: + +```shell +stackit config profile list +stackit config profile export +``` + +3. Uninstall the formula. +```shell +brew uninstall stackit +``` + +4. Install the STACKIT CLI as cask. +```shell +brew install --cask stackit +``` + +5. Check if your configs are still stored. +```shell +stackit config profile list +``` + +6. In case the profiles are gone, import your profiles via: +```shell +$ stackit config profile import -c @default.json --name myProfile ``` ### Linux @@ -27,7 +67,7 @@ brew install stackit The STACKIT CLI is available as a [Snap](https://snapcraft.io/stackit), and can be installed via: ```shell -sudo snap install stackit --beta --classic +sudo snap install stackit --classic ``` or via the [Snap Store](https://snapcraft.io/snap-store) for desktop. @@ -90,6 +130,56 @@ asset_filters=["stackit-cli_", "_linux_amd64.tar.gz"] eget stackitcloud/stackit-cli ``` +#### RHEL/Fedora/Rocky/Alma/openSUSE/... (`DNF/YUM/Zypper`) + +The STACKIT CLI can be installed through the [`DNF/YUM`](https://docs.fedoraproject.org/en-US/fedora/f40/system-administrators-guide/package-management/DNF/) / [`Zypper`](https://de.opensuse.org/Zypper) package managers. + +> Requires rpm version 4.15 or newer to support Ed25519 signatures. + +> `$basearch` is supported by modern distributions. On older systems that don't expand `$basearch`, replace it in the `baseurl` with your architecture explicitly (for example, `.../rpm/cli/x86_64` or `.../rpm/cli/aarch64`). + +##### Installation via DNF/YUM + +1. Add the repository: + +```shell +sudo tee /etc/yum.repos.d/stackit.repo > /dev/null << 'EOF' +[stackit] +name=STACKIT CLI +baseurl=https://packages.stackit.cloud/rpm/cli/$basearch +enabled=1 +gpgcheck=1 +gpgkey=https://packages.stackit.cloud/keys/key.gpg +EOF +``` + +2. Install the CLI: + +```shell +sudo dnf install stackit +``` + +##### Installation via Zypper + +1. Add the repository: + +```shell +sudo tee /etc/zypp/repos.d/stackit.repo > /dev/null << 'EOF' +[stackit] +name=STACKIT CLI +baseurl=https://packages.stackit.cloud/rpm/cli/$basearch +enabled=1 +gpgcheck=1 +gpgkey=https://packages.stackit.cloud/keys/key.gpg +EOF +``` + +2. Install the CLI: + +```shell +sudo zypper install stackit +``` + #### Any distribution Alternatively, you can install via [Homebrew](https://brew.sh/) or refer to one of the installation methods below. @@ -98,7 +188,22 @@ Alternatively, you can install via [Homebrew](https://brew.sh/) or refer to one ### Windows -> We are currently working on distributing the CLI on a package manager for Windows. For the moment, please refer to one of the installation methods below. +#### Scoop + +The STACKIT CLI can be installed through the [Scoop](https://scoop.sh/) package manager. + +1. Install Scoop (if not already installed): + +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression +``` + +2. Install the CLI: + +```powershell +scoop install stackit +``` ## Manual installation @@ -125,6 +230,24 @@ You can also get the STACKIT CLI by compiling it from source or downloading a pr go run . ``` +### FreeBSD + +The STACKIT CLI can be installed through the [FreeBSD ports or packages](https://docs.freebsd.org/en/books/handbook/ports/). + +To install the port: + +```shell +cd /usr/ports/sysutils/stackit/ && make install clean +``` + +To add the package, run one of these commands: + +```shell +pkg install sysutils/stackit +# OR +pkg install stackit +``` + ### Pre-compiled binary 1. Download the binary corresponding to your operating system and CPU architecture from our [Releases](https://github.com/stackitcloud/stackit-cli/releases) page diff --git a/Makefile b/Makefile index e7b1abba0..436a40a80 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ 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} +GOLANG_CI_ARGS ?= --allow-parallel-runners --config=${GOLANG_CI_YAML_PATH} # Build build: @@ -9,6 +9,7 @@ build: fmt: @gofmt -s -w . + @go tool golangci-lint fmt --config=${GOLANG_CI_YAML_PATH} # Lint lint-golangci-lint: @@ -24,7 +25,7 @@ lint: lint-golangci-lint lint-yamllint # Test test: @echo ">> Running tests for the CLI application" - @go test ./... -count=1 + @go test ./... -count=1 -coverprofile=coverage.out # Test coverage coverage: diff --git a/README.md b/README.md index fad966e7b..579b9a4d1 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@
-# STACKIT CLI (BETA) +# STACKIT CLI [![Go Report Card](https://goreportcard.com/badge/github.com/stackitcloud/stackit-cli)](https://goreportcard.com/report/github.com/stackitcloud/stackit-cli) ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/stackitcloud/stackit-cli) [![GitHub License](https://img.shields.io/github/license/stackitcloud/stackit-cli)](https://www.apache.org/licenses/LICENSE-2.0) -Welcome to the STACKIT CLI, a command-line interface for [STACKIT - The German business cloud](https://www.stackit.de/en). +Welcome to the STACKIT CLI, a command-line interface for [STACKIT - The sovereign cloud for Europe](https://www.stackit.de/en). The STACKIT CLI allows you to manage your STACKIT services and resources as well as perform operations using the command-line or in scripts or automation, such as: @@ -19,7 +19,6 @@ The STACKIT CLI allows you to manage your STACKIT services and resources as well - DNS zones and record-sets - Databases such as PostgreSQL Flex, MongoDB Flex and SQLServer Flex -This CLI is in a BETA state. More services and functionality will be supported soon. Your feedback is appreciated! Feel free to open [GitHub issues](https://github.com/stackitcloud/stackit-cli) to provide feature requests and bug reports. @@ -69,28 +68,34 @@ Help is available for any command by specifying the special flag `--help` (or si Below you can find a list of the STACKIT services already available in the CLI (along with their respective command names) and the ones that are currently planned to be integrated. -| Service | CLI Commands | Status | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -| Authorization | `project`, `organization` | :white_check_mark: | -| DNS | `dns` | :white_check_mark: | -| Infrastructure as a Service (IaaS) | `image`
`key-pair`
`network`
`network-area`
`network-interface`
`public-ip`
`quota`
`security-group`
`server`
`volume` | :white_check_mark:| -| Kubernetes Engine (SKE) | `ske` | :white_check_mark: | -| Load Balancer | `load-balancer` | :white_check_mark: | -| LogMe | `logme` | :white_check_mark: | -| MariaDB | `mariadb` | :white_check_mark: | -| MongoDB Flex | `mongodbflex` | :white_check_mark: | -| Observability | `observability` | :white_check_mark: | -| Object Storage | `object-storage` | :white_check_mark: | -| OpenSearch | `opensearch` | :white_check_mark: | -| PostgreSQL Flex | `postgresflex` | :white_check_mark: | -| RabbitMQ | `rabbitmq` | :white_check_mark: | -| Redis | `redis` | :white_check_mark: | -| Resource Manager | `project` | :white_check_mark: | -| Secrets Manager | `secrets-manager` | :white_check_mark: | -| Server Backup Management | `server backup` | :white_check_mark: | -| Server Command (Run Command) | `server command` | :white_check_mark: | -| Service Account | `service-account` | :white_check_mark: | -| SQLServer Flex | `beta sqlserverflex` | :white_check_mark: (beta) | +| Service | CLI Commands | Status | +|------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------| +| Application Load Balancer | `beta alb` | :white_check_mark: (beta) | +| Authorization | `project`, `organization` | :white_check_mark: | +| DNS | `dns` | :white_check_mark: | +| Edge Cloud | `beta edge-cloud` | :white_check_mark: (beta) | +| Git | `git` | :white_check_mark: | +| Infrastructure as a Service (IaaS) | `affinity-group`
`image`
`key-pair`
`network`
`network-area`
`network-interface`
`public-ip`
`quota`
`security-group`
`server`
`volume` | :white_check_mark: | +| Intake | `beta intake` | :white_check_mark: (beta) | +| Key Management Service (KMS) | `beta kms` | :white_check_mark: (beta) | +| Kubernetes Engine (SKE) | `ske` | :white_check_mark: | +| Load Balancer | `load-balancer` | :white_check_mark: | +| LogMe | `logme` | :white_check_mark: | +| MariaDB | `mariadb` | :white_check_mark: | +| MongoDB Flex | `mongodbflex` | :white_check_mark: | +| Observability | `observability` | :white_check_mark: | +| Object Storage | `object-storage` | :white_check_mark: | +| OpenSearch | `opensearch` | :white_check_mark: | +| PostgreSQL Flex | `postgresflex` | :white_check_mark: | +| RabbitMQ | `rabbitmq` | :white_check_mark: | +| Redis | `redis` | :white_check_mark: | +| Resource Manager | `project` | :white_check_mark: | +| Secrets Manager | `secrets-manager` | :white_check_mark: | +| Server Backup Management | `server backup` | :white_check_mark: | +| Server Command (Run Command) | `server command` | :white_check_mark: | +| Service Account | `service-account` | :white_check_mark: | +| SQLServer Flex | `beta sqlserverflex` | :white_check_mark: (beta) | +| File Storage (SFS) | `beta sfs` | :white_check_mark: (beta) | ## Authentication @@ -203,6 +208,6 @@ Apache 2.0 - [STACKIT](https://www.stackit.de/en/) -- [STACKIT Knowledge Base](https://docs.stackit.cloud/stackit/en/knowledge-base-85301704.html) +- [STACKIT Docs](https://docs.stackit.cloud/) - [STACKIT Terraform Provider](https://registry.terraform.io/providers/stackitcloud/stackit/latest/docs) diff --git a/aptly.rb b/aptly.rb deleted file mode 100644 index 8b1f49727..000000000 --- a/aptly.rb +++ /dev/null @@ -1,40 +0,0 @@ -class Aptly < Formula - desc "Swiss army knife for Debian repository management" - homepage "https://www.aptly.info/" - url "https://github.com/aptly-dev/aptly/archive/refs/tags/v1.5.0.tar.gz" - sha256 "07e18ce606feb8c86a1f79f7f5dd724079ac27196faa61a2cefa5b599bbb5bb1" - license "MIT" - head "https://github.com/aptly-dev/aptly.git", branch: "master" - - bottle do - rebuild 2 - sha256 cellar: :any_skip_relocation, arm64_sequoia: "f689184731329b1c22f23af361e31cd8aa6992084434d49281227654281a8f45" - sha256 cellar: :any_skip_relocation, arm64_sonoma: "0d022b595e520ea53e23b1dfceb4a45139e7e2ba735994196135c1f9c1a36d4c" - sha256 cellar: :any_skip_relocation, arm64_ventura: "c6fa91fb368a63d5558b8c287b330845e04f90bd4fe7223e161493b01747c869" - sha256 cellar: :any_skip_relocation, arm64_monterey: "19c0c8c0b35c1c5faa2a71fc0bd088725f5623f465369dcca5b2cea59322714c" - sha256 cellar: :any_skip_relocation, arm64_big_sur: "2314abe4aae7ea53660920d311cacccd168045994e1a9eddf12a381b215c1908" - sha256 cellar: :any_skip_relocation, sonoma: "0f077e265538e235ad867b39edc756180c8a0fba7ac5385ab59b18e827519f4c" - sha256 cellar: :any_skip_relocation, ventura: "d132d06243b93952309f3fbe1970d87cde272ea103cf1829c880c1b8a85a12cb" - sha256 cellar: :any_skip_relocation, monterey: "86111a102d0782a77bab0d48015bd275f120a36964d86f8f613f1a8f73d94664" - sha256 cellar: :any_skip_relocation, big_sur: "d622cfe1d925f0058f583b8bf48b0bdcee36a441f1bcf145040e5f93879f8765" - sha256 cellar: :any_skip_relocation, catalina: "5d9d495ec8215cfade3e856528dfa233496849517813b19a9ba8d60cb72c4751" - sha256 cellar: :any_skip_relocation, x86_64_linux: "bbff5503f74ef5dcaae33846e285ecf1a23c23de1c858760ae1789ef6fc99524" - end - - depends_on "go" => :build - - def install - system "go", "generate" if build.head? - system "go", "build", *std_go_args(ldflags: "-s -w -X main.Version=#{version}") - - bash_completion.install "completion.d/aptly" - end - - test do - assert_match "aptly version:", shell_output("#{bin}/aptly version") - - (testpath/".aptly.conf").write("{}") - result = shell_output("#{bin}/aptly -config='#{testpath}/.aptly.conf' mirror list") - assert_match "No mirrors found, create one with", result - end -end \ No newline at end of file diff --git a/docs/stackit.md b/docs/stackit.md index 2dc456b83..88f1a5707 100644 --- a/docs/stackit.md +++ b/docs/stackit.md @@ -5,8 +5,7 @@ Manage STACKIT resources using the command line ### Synopsis Manage STACKIT resources using the command line. -This CLI is in a BETA state. -More services and functionality will be supported soon. Your feedback is appreciated! +Your feedback is appreciated! ``` stackit [flags] @@ -33,10 +32,13 @@ stackit [flags] * [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options * [stackit curl](./stackit_curl.md) - Executes an authenticated HTTP request to an endpoint * [stackit dns](./stackit_dns.md) - Provides functionality for DNS +* [stackit git](./stackit_git.md) - Provides functionality for STACKIT Git * [stackit image](./stackit_image.md) - Manage server images * [stackit key-pair](./stackit_key-pair.md) - Provides functionality for SSH key pairs +* [stackit kms](./stackit_kms.md) - Provides functionality for KMS * [stackit load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer * [stackit logme](./stackit_logme.md) - Provides functionality for LogMe +* [stackit logs](./stackit_logs.md) - Provides functionality for Logs * [stackit mariadb](./stackit_mariadb.md) - Provides functionality for MariaDB * [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex * [stackit network](./stackit_network.md) - Provides functionality for networks diff --git a/docs/stackit_auth_login.md b/docs/stackit_auth_login.md index 8b08bc947..3cd888bd2 100644 --- a/docs/stackit_auth_login.md +++ b/docs/stackit_auth_login.md @@ -21,7 +21,9 @@ stackit auth login [flags] ### Options ``` - -h, --help Help for "stackit auth login" + -h, --help Help for "stackit auth login" + --port int The port on which the callback server will listen to. By default, it tries to bind a port between 8000 and 8020. + When a value is specified, it will only try to use the specified port. Valid values are within the range of 8000 to 8020. ``` ### Options inherited from parent commands diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index b58eb067a..9d62cd913 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -42,5 +42,9 @@ stackit beta [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers +* [stackit beta cdn](./stackit_beta_cdn.md) - Manage CDN resources +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake +* [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (stackit file storage) * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex diff --git a/docs/stackit_beta_cdn.md b/docs/stackit_beta_cdn.md new file mode 100644 index 000000000..b0a99f688 --- /dev/null +++ b/docs/stackit_beta_cdn.md @@ -0,0 +1,34 @@ +## stackit beta cdn + +Manage CDN resources + +### Synopsis + +Manage the lifecycle of CDN resources. + +``` +stackit beta cdn [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta cdn distribution](./stackit_beta_cdn_distribution.md) - Manage CDN distributions + diff --git a/docs/stackit_beta_cdn_distribution.md b/docs/stackit_beta_cdn_distribution.md new file mode 100644 index 000000000..c9c26a931 --- /dev/null +++ b/docs/stackit_beta_cdn_distribution.md @@ -0,0 +1,38 @@ +## stackit beta cdn distribution + +Manage CDN distributions + +### Synopsis + +Manage the lifecycle of CDN distributions. + +``` +stackit beta cdn distribution [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn distribution" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta cdn](./stackit_beta_cdn.md) - Manage CDN resources +* [stackit beta cdn distribution create](./stackit_beta_cdn_distribution_create.md) - Create a CDN distribution +* [stackit beta cdn distribution delete](./stackit_beta_cdn_distribution_delete.md) - Delete a CDN distribution +* [stackit beta cdn distribution describe](./stackit_beta_cdn_distribution_describe.md) - Describe a CDN distribution +* [stackit beta cdn distribution list](./stackit_beta_cdn_distribution_list.md) - List CDN distributions +* [stackit beta cdn distribution update](./stackit_beta_cdn_distribution_update.md) - Update a CDN distribution + diff --git a/docs/stackit_beta_cdn_distribution_create.md b/docs/stackit_beta_cdn_distribution_create.md new file mode 100644 index 000000000..f52da0cf1 --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_create.md @@ -0,0 +1,68 @@ +## stackit beta cdn distribution create + +Create a CDN distribution + +### Synopsis + +Create a CDN distribution for a given originUrl in multiple regions. + +``` +stackit beta cdn distribution create [flags] +``` + +### Examples + +``` + Create a CDN distribution with an HTTP backend + $ stackit beta cdn distribution create --http --http-origin-url https://example.com \ +--regions AF,EU + + Create a CDN distribution with an Object Storage backend + $ stackit beta cdn distribution create --bucket --bucket-url https://bucket.example.com \ +--bucket-credentials-access-key-id yyyy --bucket-region EU \ +--regions AF,EU + + Create a CDN distribution passing the password via stdin, take care that there's a '\n' at the end of the input' + $ cat secret.txt | stackit beta cdn distribution create -y --project-id xxx \ +--bucket --bucket-url https://bucket.example.com --bucekt-credentials-access-key-id yyyy --bucket-region EU \ +--regions AF,EU +``` + +### Options + +``` + --blocked-countries strings Comma-separated list of ISO 3166-1 alpha-2 country codes to block (e.g., 'US,DE,FR') + --blocked-ips strings Comma-separated list of IPv4 addresses to block (e.g., '10.0.0.8,127.0.0.1') + --bucket Use Object Storage backend + --bucket-credentials-access-key-id string Access Key ID for Object Storage backend + --bucket-region string Region for Object Storage backend + --bucket-url string Bucket URL for Object Storage backend + --default-cache-duration string ISO8601 duration string for default cache duration (e.g., 'PT1H30M' for 1 hour and 30 minutes) + -h, --help Help for "stackit beta cdn distribution create" + --http Use HTTP backend + --http-geofencing stringArray Geofencing rules for HTTP backend in the format 'https://example.com US,DE'. URL and countries have to be quoted. Repeatable. + --http-origin-request-headers strings Origin request headers for HTTP backend in the format 'HeaderName: HeaderValue', repeatable. WARNING: do not store sensitive values in the headers! + --http-origin-url string Origin URL for HTTP backend + --loki Enable Loki log sink for the CDN distribution + --loki-push-url string Push URL for log sink + --loki-username string Username for log sink + --monthly-limit-bytes int Monthly limit in bytes for the CDN distribution + --optimizer Enable optimizer for the CDN distribution (paid feature). + --regions strings Regions in which content should be cached, multiple of: ["EU" "US" "AF" "SA" "ASIA"] (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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta cdn distribution](./stackit_beta_cdn_distribution.md) - Manage CDN distributions + diff --git a/docs/stackit_beta_cdn_distribution_delete.md b/docs/stackit_beta_cdn_distribution_delete.md new file mode 100644 index 000000000..7313b5a39 --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_delete.md @@ -0,0 +1,40 @@ +## stackit beta cdn distribution delete + +Delete a CDN distribution + +### Synopsis + +Delete a CDN distribution by its ID. + +``` +stackit beta cdn distribution delete [flags] +``` + +### Examples + +``` + Delete a CDN distribution with ID "xxx" + $ stackit beta cdn distribution delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn distribution 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta cdn distribution](./stackit_beta_cdn_distribution.md) - Manage CDN distributions + diff --git a/docs/stackit_beta_cdn_distribution_describe.md b/docs/stackit_beta_cdn_distribution_describe.md new file mode 100644 index 000000000..1e8f68a7e --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_describe.md @@ -0,0 +1,44 @@ +## stackit beta cdn distribution describe + +Describe a CDN distribution + +### Synopsis + +Describe a CDN distribution by its ID. + +``` +stackit beta cdn distribution describe [flags] +``` + +### Examples + +``` + Get details of a CDN distribution with ID "xxx" + $ stackit beta cdn distribution describe xxx + + Get details of a CDN, including WAF details, for ID "xxx" + $ stackit beta cdn distribution describe xxx --with-waf +``` + +### Options + +``` + -h, --help Help for "stackit beta cdn distribution describe" + --with-waf Include WAF details in the distribution description +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta cdn distribution](./stackit_beta_cdn_distribution.md) - Manage CDN distributions + diff --git a/docs/stackit_beta_cdn_distribution_list.md b/docs/stackit_beta_cdn_distribution_list.md new file mode 100644 index 000000000..4fc5d2750 --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_list.md @@ -0,0 +1,45 @@ +## stackit beta cdn distribution list + +List CDN distributions + +### Synopsis + +List all CDN distributions in your account. + +``` +stackit beta cdn distribution list [flags] +``` + +### Examples + +``` + List all CDN distributions + $ stackit beta cdn distribution list + + List all CDN distributions sorted by id + $ stackit beta cdn distribution list --sort-by=id +``` + +### Options + +``` + -- int Limit the output to the first n elements + -h, --help Help for "stackit beta cdn distribution list" + --sort-by string Sort entries by a specific field, one of ["id" "createdAt" "updatedAt" "originUrl" "status" "originUrlRelated"] (default "createdAt") +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta cdn distribution](./stackit_beta_cdn_distribution.md) - Manage CDN distributions + diff --git a/docs/stackit_beta_cdn_distribution_update.md b/docs/stackit_beta_cdn_distribution_update.md new file mode 100644 index 000000000..435429c6a --- /dev/null +++ b/docs/stackit_beta_cdn_distribution_update.md @@ -0,0 +1,57 @@ +## stackit beta cdn distribution update + +Update a CDN distribution + +### Synopsis + +Update a CDN distribution by its ID, allowing replacement of its regions. + +``` +stackit beta cdn distribution update [flags] +``` + +### Examples + +``` + update a CDN distribution with ID "xxx" to not use optimizer + $ stackit beta cdn distribution update xxx --optimizer=false +``` + +### Options + +``` + --blocked-countries strings Comma-separated list of ISO 3166-1 alpha-2 country codes to block (e.g., 'US,DE,FR') + --blocked-ips strings Comma-separated list of IPv4 addresses to block (e.g., '10.0.0.8,127.0.0.1') + --bucket Use Object Storage backend + --bucket-credentials-access-key-id string Access Key ID for Object Storage backend + --bucket-region string Region for Object Storage backend + --bucket-url string Bucket URL for Object Storage backend + --default-cache-duration string ISO8601 duration string for default cache duration (e.g., 'PT1H30M' for 1 hour and 30 minutes) + -h, --help Help for "stackit beta cdn distribution update" + --http Use HTTP backend + --http-geofencing stringArray Geofencing rules for HTTP backend in the format 'https://example.com US,DE'. URL and countries have to be quoted. Repeatable. + --http-origin-request-headers strings Origin request headers for HTTP backend in the format 'HeaderName: HeaderValue', repeatable. WARNING: do not store sensitive values in the headers! + --http-origin-url string Origin URL for HTTP backend + --loki Enable Loki log sink for the CDN distribution + --loki-push-url string Push URL for log sink + --loki-username string Username for log sink + --monthly-limit-bytes int Monthly limit in bytes for the CDN distribution + --optimizer Enable optimizer for the CDN distribution (paid feature). + --regions strings Regions in which content should be cached, multiple of: ["EU" "US" "AF" "SA" "ASIA"] (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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta cdn distribution](./stackit_beta_cdn_distribution.md) - Manage CDN distributions + diff --git a/docs/stackit_beta_edge-cloud.md b/docs/stackit_beta_edge-cloud.md new file mode 100644 index 000000000..161433446 --- /dev/null +++ b/docs/stackit_beta_edge-cloud.md @@ -0,0 +1,37 @@ +## stackit beta edge-cloud + +Provides functionality for edge services. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) services. + +``` +stackit beta edge-cloud [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. +* [stackit beta edge-cloud kubeconfig](./stackit_beta_edge-cloud_kubeconfig.md) - Provides functionality for edge kubeconfig. +* [stackit beta edge-cloud plans](./stackit_beta_edge-cloud_plans.md) - Provides functionality for edge service plans. +* [stackit beta edge-cloud token](./stackit_beta_edge-cloud_token.md) - Provides functionality for edge service token. + diff --git a/docs/stackit_beta_edge-cloud_instance.md b/docs/stackit_beta_edge-cloud_instance.md new file mode 100644 index 000000000..853ac56f0 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance.md @@ -0,0 +1,38 @@ +## stackit beta edge-cloud instance + +Provides functionality for edge instances. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) instance management. + +``` +stackit beta edge-cloud instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud instance create](./stackit_beta_edge-cloud_instance_create.md) - Creates an edge instance +* [stackit beta edge-cloud instance delete](./stackit_beta_edge-cloud_instance_delete.md) - Deletes an edge instance +* [stackit beta edge-cloud instance describe](./stackit_beta_edge-cloud_instance_describe.md) - Describes an edge instance +* [stackit beta edge-cloud instance list](./stackit_beta_edge-cloud_instance_list.md) - Lists edge instances +* [stackit beta edge-cloud instance update](./stackit_beta_edge-cloud_instance_update.md) - Updates an edge instance + diff --git a/docs/stackit_beta_edge-cloud_instance_create.md b/docs/stackit_beta_edge-cloud_instance_create.md new file mode 100644 index 000000000..78c123ec1 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_create.md @@ -0,0 +1,43 @@ +## stackit beta edge-cloud instance create + +Creates an edge instance + +### Synopsis + +Creates a STACKIT Edge Cloud (STEC) instance. The instance will take a moment to become fully functional. + +``` +stackit beta edge-cloud instance create [flags] +``` + +### Examples + +``` + Creates an edge instance with the name "xxx" and plan-id "yyy" + $ stackit beta edge-cloud instance create --name "xxx" --plan-id "yyy" +``` + +### Options + +``` + -d, --description string A user chosen description to distinguish multiple instances. + -h, --help Help for "stackit beta edge-cloud instance create" + -n, --name string The displayed name to distinguish multiple instances. + --plan-id string Service Plan configures the size 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_instance_delete.md b/docs/stackit_beta_edge-cloud_instance_delete.md new file mode 100644 index 000000000..b8aa5834d --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_delete.md @@ -0,0 +1,45 @@ +## stackit beta edge-cloud instance delete + +Deletes an edge instance + +### Synopsis + +Deletes a STACKIT Edge Cloud (STEC) instance. The instance will be deleted permanently. + +``` +stackit beta edge-cloud instance delete [flags] +``` + +### Examples + +``` + Delete an edge instance with id "xxx" + $ stackit beta edge-cloud instance delete --id "xxx" + + Delete an edge instance with name "xxx" + $ stackit beta edge-cloud instance delete --name "xxx" +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud instance delete" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_instance_describe.md b/docs/stackit_beta_edge-cloud_instance_describe.md new file mode 100644 index 000000000..534bc9cf0 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_describe.md @@ -0,0 +1,45 @@ +## stackit beta edge-cloud instance describe + +Describes an edge instance + +### Synopsis + +Describes a STACKIT Edge Cloud (STEC) instance. + +``` +stackit beta edge-cloud instance describe [flags] +``` + +### Examples + +``` + Describe an edge instance with id "xxx" + $ stackit beta edge-cloud instance describe --id + + Describe an edge instance with name "xxx" + $ stackit beta edge-cloud instance describe --name +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud instance describe" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_instance_list.md b/docs/stackit_beta_edge-cloud_instance_list.md new file mode 100644 index 000000000..e605d6f64 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_list.md @@ -0,0 +1,44 @@ +## stackit beta edge-cloud instance list + +Lists edge instances + +### Synopsis + +Lists STACKIT Edge Cloud (STEC) instances of a project. + +``` +stackit beta edge-cloud instance list [flags] +``` + +### Examples + +``` + Lists all edge instances of a given project + $ stackit beta edge-cloud instance list + + Lists all edge instances of a given project and limits the output to two instances + $ stackit beta edge-cloud instance list --limit 2 +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_instance_update.md b/docs/stackit_beta_edge-cloud_instance_update.md new file mode 100644 index 000000000..9f3cb39b7 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_instance_update.md @@ -0,0 +1,50 @@ +## stackit beta edge-cloud instance update + +Updates an edge instance + +### Synopsis + +Updates a STACKIT Edge Cloud (STEC) instance. + +``` +stackit beta edge-cloud instance update [flags] +``` + +### Examples + +``` + Updates the description of an edge instance with id "xxx" + $ stackit beta edge-cloud instance update --id "xxx" --description "yyy" + + Updates the plan of an edge instance with name "xxx" + $ stackit beta edge-cloud instance update --name "xxx" --plan-id "yyy" + + Updates the description and plan of an edge instance with id "xxx" + $ stackit beta edge-cloud instance update --id "xxx" --description "yyy" --plan-id "zzz" +``` + +### Options + +``` + -d, --description string A user chosen description to distinguish multiple instances. + -h, --help Help for "stackit beta edge-cloud instance update" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. + --plan-id string Service Plan configures the size 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud instance](./stackit_beta_edge-cloud_instance.md) - Provides functionality for edge instances. + diff --git a/docs/stackit_beta_edge-cloud_kubeconfig.md b/docs/stackit_beta_edge-cloud_kubeconfig.md new file mode 100644 index 000000000..be5078f00 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_kubeconfig.md @@ -0,0 +1,34 @@ +## stackit beta edge-cloud kubeconfig + +Provides functionality for edge kubeconfig. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) kubeconfig management. + +``` +stackit beta edge-cloud kubeconfig [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud kubeconfig" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud kubeconfig create](./stackit_beta_edge-cloud_kubeconfig_create.md) - Creates or updates a local kubeconfig file of an edge instance + diff --git a/docs/stackit_beta_edge-cloud_kubeconfig_create.md b/docs/stackit_beta_edge-cloud_kubeconfig_create.md new file mode 100644 index 000000000..2d9a5ad40 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_kubeconfig_create.md @@ -0,0 +1,61 @@ +## stackit beta edge-cloud kubeconfig create + +Creates or updates a local kubeconfig file of an edge instance + +### Synopsis + +Creates or updates a local kubeconfig file of a STACKIT Edge Cloud (STEC) instance. If the config exists in the kubeconfig file, the information will be updated. + +By default, the kubeconfig information of the edge instance is merged into the current kubeconfig file which is determined by Kubernetes client logic. If the kubeconfig file doesn't exist, a new one will be created. +You can override this behavior by specifying a custom filepath with the --filepath flag or disable writing with the --disable-writing flag. +An expiration time can be set for the kubeconfig. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is 3600 seconds. +Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units. + +``` +stackit beta edge-cloud kubeconfig create [flags] +``` + +### Examples + +``` + Create or update a kubeconfig for the edge instance with id "xxx". If the config exists in the kubeconfig file, the information will be updated. + $ stackit beta edge-cloud kubeconfig create --id "xxx" + + Create or update a kubeconfig for the edge instance with name "xxx" in a custom filepath. + $ stackit beta edge-cloud kubeconfig create --name "xxx" --filepath "yyy" + + Get a kubeconfig for the edge instance with name "xxx" without writing it to a file and format the output as json. + $ stackit beta edge-cloud kubeconfig create --name "xxx" --disable-writing --output-format json + + Create a kubeconfig for the edge instance with id "xxx". This will replace your current kubeconfig file. + $ stackit beta edge-cloud kubeconfig create --id "xxx" --overwrite +``` + +### Options + +``` + --disable-writing Disable writing the kubeconfig to a file. + -e, --expiration string Expiration time for the kubeconfig, e.g. 5d. By default, the token is valid for 1h. + -f, --filepath string Path to the kubeconfig file. A default is chosen by Kubernetes if not set. + -h, --help Help for "stackit beta edge-cloud kubeconfig create" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. + --overwrite Force overwrite the kubeconfig file if it exists. + --switch-context Switch to the context in the kubeconfig file to the new context. +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud kubeconfig](./stackit_beta_edge-cloud_kubeconfig.md) - Provides functionality for edge kubeconfig. + diff --git a/docs/stackit_beta_edge-cloud_plans.md b/docs/stackit_beta_edge-cloud_plans.md new file mode 100644 index 000000000..c58e5a8e1 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_plans.md @@ -0,0 +1,34 @@ +## stackit beta edge-cloud plans + +Provides functionality for edge service plans. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) service plan management. + +``` +stackit beta edge-cloud plans [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud plans" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud plans list](./stackit_beta_edge-cloud_plans_list.md) - Lists available edge service plans + diff --git a/docs/stackit_beta_edge-cloud_plans_list.md b/docs/stackit_beta_edge-cloud_plans_list.md new file mode 100644 index 000000000..a57c7e197 --- /dev/null +++ b/docs/stackit_beta_edge-cloud_plans_list.md @@ -0,0 +1,44 @@ +## stackit beta edge-cloud plans list + +Lists available edge service plans + +### Synopsis + +Lists available STACKIT Edge Cloud (STEC) service plans of a project + +``` +stackit beta edge-cloud plans list [flags] +``` + +### Examples + +``` + Lists all edge plans for a given project + $ stackit beta edge-cloud plan list + + Lists all edge plans for a given project and limits the output to two plans + $ stackit beta edge-cloud plan list --limit 2 +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud plans 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud plans](./stackit_beta_edge-cloud_plans.md) - Provides functionality for edge service plans. + diff --git a/docs/stackit_beta_edge-cloud_token.md b/docs/stackit_beta_edge-cloud_token.md new file mode 100644 index 000000000..ba7fe0b3a --- /dev/null +++ b/docs/stackit_beta_edge-cloud_token.md @@ -0,0 +1,34 @@ +## stackit beta edge-cloud token + +Provides functionality for edge service token. + +### Synopsis + +Provides functionality for STACKIT Edge Cloud (STEC) token management. + +``` +stackit beta edge-cloud token [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta edge-cloud 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud](./stackit_beta_edge-cloud.md) - Provides functionality for edge services. +* [stackit beta edge-cloud token create](./stackit_beta_edge-cloud_token_create.md) - Creates a token for an edge instance + diff --git a/docs/stackit_beta_edge-cloud_token_create.md b/docs/stackit_beta_edge-cloud_token_create.md new file mode 100644 index 000000000..4d96d548c --- /dev/null +++ b/docs/stackit_beta_edge-cloud_token_create.md @@ -0,0 +1,49 @@ +## stackit beta edge-cloud token create + +Creates a token for an edge instance + +### Synopsis + +Creates a token for a STACKIT Edge Cloud (STEC) instance. + +An expiration time can be set for the token. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is 3600 seconds. +Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units. + +``` +stackit beta edge-cloud token create [flags] +``` + +### Examples + +``` + Create a token for the edge instance with id "xxx". + $ stackit beta edge-cloud token create --id "xxx" + + Create a token for the edge instance with name "xxx". The token will be valid for one day. + $ stackit beta edge-cloud token create --name "xxx" --expiration 1d +``` + +### Options + +``` + -e, --expiration string Expiration time for the kubeconfig, e.g. 5d. By default, the token is valid for 1h. + -h, --help Help for "stackit beta edge-cloud token create" + -i, --id string The project-unique identifier of this instance. + -n, --name string The displayed name to distinguish multiple instances. +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta edge-cloud token](./stackit_beta_edge-cloud_token.md) - Provides functionality for edge service token. + diff --git a/docs/stackit_beta_intake.md b/docs/stackit_beta_intake.md new file mode 100644 index 000000000..fa29e493f --- /dev/null +++ b/docs/stackit_beta_intake.md @@ -0,0 +1,40 @@ +## stackit beta intake + +Provides functionality for intake + +### Synopsis + +Provides functionality for intake. + +``` +stackit beta intake [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta intake" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta intake create](./stackit_beta_intake_create.md) - Creates a new Intake +* [stackit beta intake delete](./stackit_beta_intake_delete.md) - Deletes an Intake +* [stackit beta intake describe](./stackit_beta_intake_describe.md) - Shows details of an Intake +* [stackit beta intake list](./stackit_beta_intake_list.md) - Lists all Intakes +* [stackit beta intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners +* [stackit beta intake update](./stackit_beta_intake_update.md) - Updates an Intake +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_create.md b/docs/stackit_beta_intake_create.md new file mode 100644 index 000000000..58e872d10 --- /dev/null +++ b/docs/stackit_beta_intake_create.md @@ -0,0 +1,59 @@ +## stackit beta intake create + +Creates a new Intake + +### Synopsis + +Creates a new Intake. + +``` +stackit beta intake create [flags] +``` + +### Examples + +``` + Create a new Intake with required parameters + $ stackit beta intake create --display-name my-intake --runner-id xxx --catalog-auth-type none --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" + + Create a new Intake with a description, labels, and Dremio authentication + $ stackit beta intake create --display-name my-intake --runner-id xxx --description "Production intake" --labels "env=prod,team=billing" --catalog-auth-type "dremio" --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" --dremio-token-endpoint "https://auth.dremio.cloud/oauth/token" --dremio-pat "MY_TOKEN" + + Create a new Intake with manual partitioning by a date field + $ stackit beta intake create --display-name my-partitioned-intake --runner-id xxx --catalog-auth-type "none" --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" --catalog-partitioning "manual" --catalog-partition-by "day(__intake_ts)" +``` + +### Options + +``` + --catalog-auth-type string Authentication type for the catalog (e.g., 'none', 'dremio') + --catalog-namespace string The namespace to which data shall be written (default: 'intake') + --catalog-partition-by strings List of Iceberg partitioning expressions. Only used when --catalog-partitioning is 'manual' + --catalog-partitioning string The target table's partitioning. One of 'none', 'intake-time', 'manual' + --catalog-table-name string The table name to identify the table in Iceberg + --catalog-uri string The URI to the Iceberg catalog endpoint + --catalog-warehouse string The Iceberg warehouse to connect to + --description string Description + --display-name string Display name + --dremio-pat string Dremio personal access token. Required if auth-type is 'dremio' + --dremio-token-endpoint string Dremio OAuth 2.0 token endpoint URL. Required if auth-type is 'dremio' + -h, --help Help for "stackit beta intake create" + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2" (default []) + --runner-id string The UUID of the Intake Runner to use +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_delete.md b/docs/stackit_beta_intake_delete.md new file mode 100644 index 000000000..305b81f90 --- /dev/null +++ b/docs/stackit_beta_intake_delete.md @@ -0,0 +1,40 @@ +## stackit beta intake delete + +Deletes an Intake + +### Synopsis + +Deletes an Intake. + +``` +stackit beta intake delete INTAKE_ID [flags] +``` + +### Examples + +``` + Delete an Intake with ID "xxx" + $ stackit beta intake delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta intake 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_describe.md b/docs/stackit_beta_intake_describe.md new file mode 100644 index 000000000..9d13cc023 --- /dev/null +++ b/docs/stackit_beta_intake_describe.md @@ -0,0 +1,43 @@ +## stackit beta intake describe + +Shows details of an Intake + +### Synopsis + +Shows details of an Intake. + +``` +stackit beta intake describe INTAKE_ID [flags] +``` + +### Examples + +``` + Get details of an Intake with ID "xxx" + $ stackit beta intake describe xxx + + Get details of an Intake with ID "xxx" in JSON format + $ stackit beta intake describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta intake 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_list.md b/docs/stackit_beta_intake_list.md new file mode 100644 index 000000000..5086a65fe --- /dev/null +++ b/docs/stackit_beta_intake_list.md @@ -0,0 +1,47 @@ +## stackit beta intake list + +Lists all Intakes + +### Synopsis + +Lists all Intakes for the current project. + +``` +stackit beta intake list [flags] +``` + +### Examples + +``` + List all Intakes + $ stackit beta intake list + + List all Intakes in JSON format + $ stackit beta intake list --output-format json + + List up to 5 Intakes + $ stackit beta intake list --limit 5 +``` + +### Options + +``` + -h, --help Help for "stackit beta intake 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_runner.md b/docs/stackit_beta_intake_runner.md new file mode 100644 index 000000000..7d5c60ff3 --- /dev/null +++ b/docs/stackit_beta_intake_runner.md @@ -0,0 +1,38 @@ +## stackit beta intake runner + +Provides functionality for Intake Runners + +### Synopsis + +Provides functionality for Intake Runners. + +``` +stackit beta intake runner [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta intake runner" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake +* [stackit beta intake runner create](./stackit_beta_intake_runner_create.md) - Creates a new Intake Runner +* [stackit beta intake runner delete](./stackit_beta_intake_runner_delete.md) - Deletes an Intake Runner +* [stackit beta intake runner describe](./stackit_beta_intake_runner_describe.md) - Shows details of an Intake Runner +* [stackit beta intake runner list](./stackit_beta_intake_runner_list.md) - Lists all Intake Runners +* [stackit beta intake runner update](./stackit_beta_intake_runner_update.md) - Updates an Intake Runner + diff --git a/docs/stackit_beta_intake_runner_create.md b/docs/stackit_beta_intake_runner_create.md new file mode 100644 index 000000000..8903cef9d --- /dev/null +++ b/docs/stackit_beta_intake_runner_create.md @@ -0,0 +1,48 @@ +## stackit beta intake runner create + +Creates a new Intake Runner + +### Synopsis + +Creates a new Intake Runner. + +``` +stackit beta intake runner create [flags] +``` + +### Examples + +``` + Create a new Intake Runner with a display name and message capacity limits + $ stackit beta intake runner create --display-name my-runner --max-message-size-kib 1000 --max-messages-per-hour 5000 + + Create a new Intake Runner with a description and labels + $ stackit beta intake runner create --display-name my-runner --max-message-size-kib 1000 --max-messages-per-hour 5000 --description "Main runner for production" --labels="env=prod,team=billing" +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake runner create" + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2" (default []) + --max-message-size-kib int Maximum message size in KiB + --max-messages-per-hour int Maximum number of messages per hour +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner_delete.md b/docs/stackit_beta_intake_runner_delete.md new file mode 100644 index 000000000..0fa94ae5f --- /dev/null +++ b/docs/stackit_beta_intake_runner_delete.md @@ -0,0 +1,40 @@ +## stackit beta intake runner delete + +Deletes an Intake Runner + +### Synopsis + +Deletes an Intake Runner. + +``` +stackit beta intake runner delete RUNNER_ID [flags] +``` + +### Examples + +``` + Delete an Intake Runner with ID "xxx" + $ stackit beta intake runner delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta intake runner 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner_describe.md b/docs/stackit_beta_intake_runner_describe.md new file mode 100644 index 000000000..11814b10d --- /dev/null +++ b/docs/stackit_beta_intake_runner_describe.md @@ -0,0 +1,43 @@ +## stackit beta intake runner describe + +Shows details of an Intake Runner + +### Synopsis + +Shows details of an Intake Runner. + +``` +stackit beta intake runner describe RUNNER_ID [flags] +``` + +### Examples + +``` + Get details of an Intake Runner with ID "xxx" + $ stackit beta intake runner describe xxx + + Get details of an Intake Runner with ID "xxx" in JSON format + $ stackit beta intake runner describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta intake runner 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner_list.md b/docs/stackit_beta_intake_runner_list.md new file mode 100644 index 000000000..aaf5c9e59 --- /dev/null +++ b/docs/stackit_beta_intake_runner_list.md @@ -0,0 +1,47 @@ +## stackit beta intake runner list + +Lists all Intake Runners + +### Synopsis + +Lists all Intake Runners for the current project. + +``` +stackit beta intake runner list [flags] +``` + +### Examples + +``` + List all Intake Runners + $ stackit beta intake runner list + + List all Intake Runners in JSON format + $ stackit beta intake runner list --output-format json + + List up to 5 Intake Runners + $ stackit beta intake runner list --limit 5 +``` + +### Options + +``` + -h, --help Help for "stackit beta intake runner 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_runner_update.md b/docs/stackit_beta_intake_runner_update.md new file mode 100644 index 000000000..d02cb7c84 --- /dev/null +++ b/docs/stackit_beta_intake_runner_update.md @@ -0,0 +1,48 @@ +## stackit beta intake runner update + +Updates an Intake Runner + +### Synopsis + +Updates an Intake Runner. Only the specified fields are updated. + +``` +stackit beta intake runner update RUNNER_ID [flags] +``` + +### Examples + +``` + Update the display name of an Intake Runner with ID "xxx" + $ stackit beta intake runner update xxx --display-name "new-runner-name" + + Update the message capacity limits for an Intake Runner with ID "xxx" + $ stackit beta intake runner update xxx --max-message-size-kib 1000 --max-messages-per-hour 10000 +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake runner update" + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) + --max-message-size-kib int Maximum message size in KiB. Note: Overall message capacity cannot be decreased. + --max-messages-per-hour int Maximum number of messages per hour. Note: Overall message capacity cannot be decreased. +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners + diff --git a/docs/stackit_beta_intake_update.md b/docs/stackit_beta_intake_update.md new file mode 100644 index 000000000..3b36ac0b7 --- /dev/null +++ b/docs/stackit_beta_intake_update.md @@ -0,0 +1,54 @@ +## stackit beta intake update + +Updates an Intake + +### Synopsis + +Updates an Intake. Only the specified fields are updated. + +``` +stackit beta intake update INTAKE_ID [flags] +``` + +### Examples + +``` + Update the display name of an Intake with ID "xxx" + $ stackit beta intake update xxx --runner-id yyy --display-name new-intake-name + + Update the catalog details for an Intake with ID "xxx" + $ stackit beta intake update xxx --runner-id yyy --catalog-uri "http://new.uri" --catalog-warehouse "new-warehouse" +``` + +### Options + +``` + --catalog-auth-type string Authentication type for the catalog (e.g., 'none', 'dremio') + --catalog-namespace string The namespace to which data shall be written + --catalog-table-name string The table name to identify the table in Iceberg + --catalog-uri string The URI to the Iceberg catalog endpoint + --catalog-warehouse string The Iceberg warehouse to connect to + --description string Description + --display-name string Display name + --dremio-pat string Dremio personal access token + --dremio-token-endpoint string Dremio OAuth 2.0 token endpoint URL + -h, --help Help for "stackit beta intake update" + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) + --runner-id string The UUID of the Intake Runner to use +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake + diff --git a/docs/stackit_beta_intake_user.md b/docs/stackit_beta_intake_user.md new file mode 100644 index 000000000..a09b51fb1 --- /dev/null +++ b/docs/stackit_beta_intake_user.md @@ -0,0 +1,38 @@ +## stackit beta intake user + +Provides functionality for Intake Users + +### Synopsis + +Provides functionality for Intake Users. + +``` +stackit beta intake user [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta intake 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake](./stackit_beta_intake.md) - Provides functionality for intake +* [stackit beta intake user create](./stackit_beta_intake_user_create.md) - Creates a new Intake User +* [stackit beta intake user delete](./stackit_beta_intake_user_delete.md) - Deletes an Intake User +* [stackit beta intake user describe](./stackit_beta_intake_user_describe.md) - Shows details of an Intake User +* [stackit beta intake user list](./stackit_beta_intake_user_list.md) - Lists all Intake Users +* [stackit beta intake user update](./stackit_beta_intake_user_update.md) - Updates an Intake User + diff --git a/docs/stackit_beta_intake_user_create.md b/docs/stackit_beta_intake_user_create.md new file mode 100644 index 000000000..84ec49bdf --- /dev/null +++ b/docs/stackit_beta_intake_user_create.md @@ -0,0 +1,49 @@ +## stackit beta intake user create + +Creates a new Intake User + +### Synopsis + +Creates a new Intake User for a specific Intake. + +``` +stackit beta intake user create [flags] +``` + +### Examples + +``` + Create a new Intake User with required parameters + $ stackit beta intake user create --display-name intake-user --intake-id xxx --password "SuperSafepass123\!" + + Create a new Intake User for the dead-letter queue with labels + $ stackit beta intake user create --display-name dlq-user --intake-id xxx --password "SuperSafepass123\!" --type dead-letter --labels "env=prod" +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake user create" + --intake-id string The UUID of the Intake to associate the user with + --labels stringToString Labels in key=value format, separated by commas (default []) + --password string Password for the user. Must contain lower, upper, number, and special characters (min 12 chars) + --type string Type of user. One of 'intake' (default) or 'dead-letter' (default "intake") +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_delete.md b/docs/stackit_beta_intake_user_delete.md new file mode 100644 index 000000000..cf6fad990 --- /dev/null +++ b/docs/stackit_beta_intake_user_delete.md @@ -0,0 +1,41 @@ +## stackit beta intake user delete + +Deletes an Intake User + +### Synopsis + +Deletes an Intake User. + +``` +stackit beta intake user delete USER_ID [flags] +``` + +### Examples + +``` + Delete an Intake User with ID "xxx" for Intake "yyy" + $ stackit beta intake user delete xxx --intake-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user delete" + --intake-id string Intake 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_describe.md b/docs/stackit_beta_intake_user_describe.md new file mode 100644 index 000000000..18f04e693 --- /dev/null +++ b/docs/stackit_beta_intake_user_describe.md @@ -0,0 +1,44 @@ +## stackit beta intake user describe + +Shows details of an Intake User + +### Synopsis + +Shows details of an Intake User. + +``` +stackit beta intake user describe USER_ID [flags] +``` + +### Examples + +``` + Get details of an Intake User with ID "xxx" for Intake "yyy" + $ stackit beta intake user describe xxx --intake-id yyy + + Get details of an Intake User with ID "xxx" in JSON format + $ stackit beta intake user describe xxx --intake-id yyy --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user describe" + --intake-id string Intake 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_list.md b/docs/stackit_beta_intake_user_list.md new file mode 100644 index 000000000..b1a25a14a --- /dev/null +++ b/docs/stackit_beta_intake_user_list.md @@ -0,0 +1,48 @@ +## stackit beta intake user list + +Lists all Intake Users + +### Synopsis + +Lists all Intake Users for a specific Intake. + +``` +stackit beta intake user list [flags] +``` + +### Examples + +``` + List all users for an Intake + $ stackit beta intake user list --intake-id xxx + + List all users for an Intake in JSON format + $ stackit beta intake user list --intake-id xxx --output-format json + + List up to 5 users for an Intake + $ stackit beta intake user list --intake-id xxx --limit 5 +``` + +### Options + +``` + -h, --help Help for "stackit beta intake user list" + --intake-id string Intake 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_intake_user_update.md b/docs/stackit_beta_intake_user_update.md new file mode 100644 index 000000000..93c591a15 --- /dev/null +++ b/docs/stackit_beta_intake_user_update.md @@ -0,0 +1,49 @@ +## stackit beta intake user update + +Updates an Intake User + +### Synopsis + +Updates an Intake User. Only the specified fields are updated. + +``` +stackit beta intake user update USER_ID [flags] +``` + +### Examples + +``` + Update the display name of an Intake User + $ stackit beta intake user update xxx --intake-id yyy --display-name "new-user-name" + + Update the password and description for an Intake User + $ stackit beta intake user update xxx --intake-id yyy --password "NewSecret123\!" --description "Updated description" +``` + +### Options + +``` + --description string Description + --display-name string Display name + -h, --help Help for "stackit beta intake user update" + --intake-id string Intake ID + --labels stringToString Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2". (default []) + --password string Password for the user. Must contain lower, upper, number, and special characters (min 12 chars) + --type string Type of user. One of 'intake' or 'dead-letter' +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta intake user](./stackit_beta_intake_user.md) - Provides functionality for Intake Users + diff --git a/docs/stackit_beta_sfs.md b/docs/stackit_beta_sfs.md new file mode 100644 index 000000000..7067bb52b --- /dev/null +++ b/docs/stackit_beta_sfs.md @@ -0,0 +1,38 @@ +## stackit beta sfs + +Provides functionality for SFS (stackit file storage) + +### Synopsis + +Provides functionality for SFS (stackit file storage). + +``` +stackit beta sfs [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands +* [stackit beta sfs export-policy](./stackit_beta_sfs_export-policy.md) - Provides functionality for SFS export policies +* [stackit beta sfs performance-class](./stackit_beta_sfs_performance-class.md) - Provides functionality for SFS performance classes +* [stackit beta sfs resource-pool](./stackit_beta_sfs_resource-pool.md) - Provides functionality for SFS resource pools +* [stackit beta sfs share](./stackit_beta_sfs_share.md) - Provides functionality for SFS shares +* [stackit beta sfs snapshot](./stackit_beta_sfs_snapshot.md) - Provides functionality for SFS snapshots + diff --git a/docs/stackit_beta_sfs_export-policy.md b/docs/stackit_beta_sfs_export-policy.md new file mode 100644 index 000000000..eaad44e74 --- /dev/null +++ b/docs/stackit_beta_sfs_export-policy.md @@ -0,0 +1,38 @@ +## stackit beta sfs export-policy + +Provides functionality for SFS export policies + +### Synopsis + +Provides functionality for SFS export policies. + +``` +stackit beta sfs export-policy [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs export-policy" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (stackit file storage) +* [stackit beta sfs export-policy create](./stackit_beta_sfs_export-policy_create.md) - Creates a export policy +* [stackit beta sfs export-policy delete](./stackit_beta_sfs_export-policy_delete.md) - Deletes a export policy +* [stackit beta sfs export-policy describe](./stackit_beta_sfs_export-policy_describe.md) - Shows details of a export policy +* [stackit beta sfs export-policy list](./stackit_beta_sfs_export-policy_list.md) - Lists all export policies of a project +* [stackit beta sfs export-policy update](./stackit_beta_sfs_export-policy_update.md) - Updates a export policy + diff --git a/docs/stackit_beta_sfs_export-policy_create.md b/docs/stackit_beta_sfs_export-policy_create.md new file mode 100644 index 000000000..87198e9c4 --- /dev/null +++ b/docs/stackit_beta_sfs_export-policy_create.md @@ -0,0 +1,45 @@ +## stackit beta sfs export-policy create + +Creates a export policy + +### Synopsis + +Creates a export policy. + +``` +stackit beta sfs export-policy create [flags] +``` + +### Examples + +``` + Create a new export policy with name "EXPORT_POLICY_NAME" + $ stackit beta sfs export-policy create --name EXPORT_POLICY_NAME + + Create a new export policy with name "EXPORT_POLICY_NAME" and rules from file "./rules.json" + $ stackit beta sfs export-policy create --name EXPORT_POLICY_NAME --rules @./rules.json +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs export-policy create" + --name string Export policy name + --rules string Rules of the export policy (format: 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs export-policy](./stackit_beta_sfs_export-policy.md) - Provides functionality for SFS export policies + diff --git a/docs/stackit_beta_sfs_export-policy_delete.md b/docs/stackit_beta_sfs_export-policy_delete.md new file mode 100644 index 000000000..af95dfa5f --- /dev/null +++ b/docs/stackit_beta_sfs_export-policy_delete.md @@ -0,0 +1,40 @@ +## stackit beta sfs export-policy delete + +Deletes a export policy + +### Synopsis + +Deletes a export policy. + +``` +stackit beta sfs export-policy delete EXPORT_POLICY_ID [flags] +``` + +### Examples + +``` + Delete a export policy with ID "xxx" + $ stackit beta sfs export-policy delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs export-policy 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs export-policy](./stackit_beta_sfs_export-policy.md) - Provides functionality for SFS export policies + diff --git a/docs/stackit_beta_sfs_export-policy_describe.md b/docs/stackit_beta_sfs_export-policy_describe.md new file mode 100644 index 000000000..79b314f38 --- /dev/null +++ b/docs/stackit_beta_sfs_export-policy_describe.md @@ -0,0 +1,40 @@ +## stackit beta sfs export-policy describe + +Shows details of a export policy + +### Synopsis + +Shows details of a export policy. + +``` +stackit beta sfs export-policy describe EXPORT_POLICY_ID [flags] +``` + +### Examples + +``` + Describe a export policy with ID "xxx" + $ stackit beta sfs export-policy describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs export-policy 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs export-policy](./stackit_beta_sfs_export-policy.md) - Provides functionality for SFS export policies + diff --git a/docs/stackit_beta_sfs_export-policy_list.md b/docs/stackit_beta_sfs_export-policy_list.md new file mode 100644 index 000000000..0611395c7 --- /dev/null +++ b/docs/stackit_beta_sfs_export-policy_list.md @@ -0,0 +1,44 @@ +## stackit beta sfs export-policy list + +Lists all export policies of a project + +### Synopsis + +Lists all export policies of a project. + +``` +stackit beta sfs export-policy list [flags] +``` + +### Examples + +``` + List all export policies + $ stackit beta sfs export-policy list + + List up to 10 export policies + $ stackit beta sfs export-policy list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs export-policy 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs export-policy](./stackit_beta_sfs_export-policy.md) - Provides functionality for SFS export policies + diff --git a/docs/stackit_beta_sfs_export-policy_update.md b/docs/stackit_beta_sfs_export-policy_update.md new file mode 100644 index 000000000..99bdec9d7 --- /dev/null +++ b/docs/stackit_beta_sfs_export-policy_update.md @@ -0,0 +1,45 @@ +## stackit beta sfs export-policy update + +Updates a export policy + +### Synopsis + +Updates a export policy. + +``` +stackit beta sfs export-policy update EXPORT_POLICY_ID [flags] +``` + +### Examples + +``` + Update a export policy with ID "xxx" and with rules from file "./rules.json" + $ stackit beta sfs export-policy update xxx --rules @./rules.json + + Update a export policy with ID "xxx" and remove the rules + $ stackit beta sfs export-policy update XXX --remove-rules +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs export-policy update" + --remove-rules Remove the export policy rules + --rules string Rules of the export policy +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs export-policy](./stackit_beta_sfs_export-policy.md) - Provides functionality for SFS export policies + diff --git a/docs/stackit_beta_sfs_performance-class.md b/docs/stackit_beta_sfs_performance-class.md new file mode 100644 index 000000000..31b10d31c --- /dev/null +++ b/docs/stackit_beta_sfs_performance-class.md @@ -0,0 +1,34 @@ +## stackit beta sfs performance-class + +Provides functionality for SFS performance classes + +### Synopsis + +Provides functionality for SFS performance classes. + +``` +stackit beta sfs performance-class [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs performance-class" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (stackit file storage) +* [stackit beta sfs performance-class list](./stackit_beta_sfs_performance-class_list.md) - Lists all performances classes available + diff --git a/docs/stackit_beta_sfs_performance-class_list.md b/docs/stackit_beta_sfs_performance-class_list.md new file mode 100644 index 000000000..14c264a8a --- /dev/null +++ b/docs/stackit_beta_sfs_performance-class_list.md @@ -0,0 +1,40 @@ +## stackit beta sfs performance-class list + +Lists all performances classes available + +### Synopsis + +Lists all performances classes available. + +``` +stackit beta sfs performance-class list [flags] +``` + +### Examples + +``` + List all performances classes + $ stackit beta sfs performance-class list +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs performance-class 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs performance-class](./stackit_beta_sfs_performance-class.md) - Provides functionality for SFS performance classes + diff --git a/docs/stackit_beta_sfs_resource-pool.md b/docs/stackit_beta_sfs_resource-pool.md new file mode 100644 index 000000000..d719f18e2 --- /dev/null +++ b/docs/stackit_beta_sfs_resource-pool.md @@ -0,0 +1,38 @@ +## stackit beta sfs resource-pool + +Provides functionality for SFS resource pools + +### Synopsis + +Provides functionality for SFS resource pools. + +``` +stackit beta sfs resource-pool [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs resource-pool" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (stackit file storage) +* [stackit beta sfs resource-pool create](./stackit_beta_sfs_resource-pool_create.md) - Creates a SFS resource pool +* [stackit beta sfs resource-pool delete](./stackit_beta_sfs_resource-pool_delete.md) - Deletes a SFS resource pool +* [stackit beta sfs resource-pool describe](./stackit_beta_sfs_resource-pool_describe.md) - Shows details of a SFS resource pool +* [stackit beta sfs resource-pool list](./stackit_beta_sfs_resource-pool_list.md) - Lists all SFS resource pools +* [stackit beta sfs resource-pool update](./stackit_beta_sfs_resource-pool_update.md) - Updates a SFS resource pool + diff --git a/docs/stackit_beta_sfs_resource-pool_create.md b/docs/stackit_beta_sfs_resource-pool_create.md new file mode 100644 index 000000000..334864945 --- /dev/null +++ b/docs/stackit_beta_sfs_resource-pool_create.md @@ -0,0 +1,58 @@ +## stackit beta sfs resource-pool create + +Creates a SFS resource pool + +### Synopsis + +Creates a SFS resource pool. + +The available performance class values can be obtained by running: + $ stackit beta sfs performance-class list + +``` +stackit beta sfs resource-pool create [flags] +``` + +### Examples + +``` + Create a SFS resource pool + $ stackit beta sfs resource-pool create --availability-zone eu01-m --ip-acl 10.88.135.144/28 --performance-class Standard --size 500 --name resource-pool-01 + + Create a SFS resource pool, allow only a single IP which can mount the resource pool + $ stackit beta sfs resource-pool create --availability-zone eu01-m --ip-acl 250.81.87.224/32 --performance-class Standard --size 500 --name resource-pool-01 + + Create a SFS resource pool, allow multiple IP ACL which can mount the resource pool + $ stackit beta sfs resource-pool create --availability-zone eu01-m --ip-acl "10.88.135.144/28,250.81.87.224/32" --performance-class Standard --size 500 --name resource-pool-01 + + Create a SFS resource pool with visible snapshots + $ stackit beta sfs resource-pool create --availability-zone eu01-m --ip-acl 10.88.135.144/28 --performance-class Standard --size 500 --name resource-pool-01 --snapshots-visible +``` + +### Options + +``` + --availability-zone string Availability zone + -h, --help Help for "stackit beta sfs resource-pool create" + --ip-acl strings List of network addresses in the form
, e.g. 192.168.10.0/24 that can mount the resource pool readonly (default []) + --name string Name + --performance-class string Performance class + --size int Size of the pool in Gigabytes + --snapshots-visible Set snapshots visible and accessible to users +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs resource-pool](./stackit_beta_sfs_resource-pool.md) - Provides functionality for SFS resource pools + diff --git a/docs/stackit_beta_sfs_resource-pool_delete.md b/docs/stackit_beta_sfs_resource-pool_delete.md new file mode 100644 index 000000000..97c52a8ae --- /dev/null +++ b/docs/stackit_beta_sfs_resource-pool_delete.md @@ -0,0 +1,40 @@ +## stackit beta sfs resource-pool delete + +Deletes a SFS resource pool + +### Synopsis + +Deletes a SFS resource pool. + +``` +stackit beta sfs resource-pool delete [flags] +``` + +### Examples + +``` + Delete the SFS resource pool with ID "xxx" + $ stackit beta sfs resource-pool delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs resource-pool 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs resource-pool](./stackit_beta_sfs_resource-pool.md) - Provides functionality for SFS resource pools + diff --git a/docs/stackit_beta_sfs_resource-pool_describe.md b/docs/stackit_beta_sfs_resource-pool_describe.md new file mode 100644 index 000000000..9b04e42bb --- /dev/null +++ b/docs/stackit_beta_sfs_resource-pool_describe.md @@ -0,0 +1,40 @@ +## stackit beta sfs resource-pool describe + +Shows details of a SFS resource pool + +### Synopsis + +Shows details of a SFS resource pool. + +``` +stackit beta sfs resource-pool describe [flags] +``` + +### Examples + +``` + Describe the SFS resource pool with ID "xxx" + $ stackit beta sfs resource-pool describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs resource-pool 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs resource-pool](./stackit_beta_sfs_resource-pool.md) - Provides functionality for SFS resource pools + diff --git a/docs/stackit_beta_sfs_resource-pool_list.md b/docs/stackit_beta_sfs_resource-pool_list.md new file mode 100644 index 000000000..103d9cc47 --- /dev/null +++ b/docs/stackit_beta_sfs_resource-pool_list.md @@ -0,0 +1,47 @@ +## stackit beta sfs resource-pool list + +Lists all SFS resource pools + +### Synopsis + +Lists all SFS resource pools. + +``` +stackit beta sfs resource-pool list [flags] +``` + +### Examples + +``` + List all SFS resource pools + $ stackit beta sfs resource-pool list + + List all SFS resource pools for another region than the default one + $ stackit beta sfs resource-pool list --region eu01 + + List up to 10 SFS resource pools + $ stackit beta sfs resource-pool list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs resource-pool 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs resource-pool](./stackit_beta_sfs_resource-pool.md) - Provides functionality for SFS resource pools + diff --git a/docs/stackit_beta_sfs_resource-pool_update.md b/docs/stackit_beta_sfs_resource-pool_update.md new file mode 100644 index 000000000..fbdc85e99 --- /dev/null +++ b/docs/stackit_beta_sfs_resource-pool_update.md @@ -0,0 +1,56 @@ +## stackit beta sfs resource-pool update + +Updates a SFS resource pool + +### Synopsis + +Updates a SFS resource pool. + +The available performance class values can be obtained by running: + $ stackit beta sfs performance-class list + +``` +stackit beta sfs resource-pool update [flags] +``` + +### Examples + +``` + Update the SFS resource pool with ID "xxx" + $ stackit beta sfs resource-pool update xxx --ip-acl 10.88.135.144/28 --performance-class Standard --size 5 + + Update the SFS resource pool with ID "xxx", allow only a single IP which can mount the resource pool + $ stackit beta sfs resource-pool update xxx --ip-acl 250.81.87.224/32 --performance-class Standard --size 5 + + Update the SFS resource pool with ID "xxx", allow multiple IP ACL which can mount the resource pool + $ stackit beta sfs resource-pool update xxx --ip-acl "10.88.135.144/28,250.81.87.224/32" --performance-class Standard --size 5 + + Update the SFS resource pool with ID "xxx", set snapshots visible to false + $ stackit beta sfs resource-pool update xxx --snapshots-visible=false +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs resource-pool update" + --ip-acl strings List of network addresses in the form
, e.g. 192.168.10.0/24 that can mount the resource pool readonly (default []) + --performance-class string Performance class + --size int Size of the pool in Gigabytes + --snapshots-visible Set snapshots visible and accessible to users +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs resource-pool](./stackit_beta_sfs_resource-pool.md) - Provides functionality for SFS resource pools + diff --git a/docs/stackit_beta_sfs_share.md b/docs/stackit_beta_sfs_share.md new file mode 100644 index 000000000..f7e75203e --- /dev/null +++ b/docs/stackit_beta_sfs_share.md @@ -0,0 +1,38 @@ +## stackit beta sfs share + +Provides functionality for SFS shares + +### Synopsis + +Provides functionality for SFS shares. + +``` +stackit beta sfs share [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs share" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (stackit file storage) +* [stackit beta sfs share create](./stackit_beta_sfs_share_create.md) - Creates a share +* [stackit beta sfs share delete](./stackit_beta_sfs_share_delete.md) - Deletes a share +* [stackit beta sfs share describe](./stackit_beta_sfs_share_describe.md) - Shows details of a shares +* [stackit beta sfs share list](./stackit_beta_sfs_share_list.md) - Lists all shares of a resource pool +* [stackit beta sfs share update](./stackit_beta_sfs_share_update.md) - Updates a share + diff --git a/docs/stackit_beta_sfs_share_create.md b/docs/stackit_beta_sfs_share_create.md new file mode 100644 index 000000000..15abc66d8 --- /dev/null +++ b/docs/stackit_beta_sfs_share_create.md @@ -0,0 +1,47 @@ +## stackit beta sfs share create + +Creates a share + +### Synopsis + +Creates a share. + +``` +stackit beta sfs share create [flags] +``` + +### Examples + +``` + Create a share in a resource pool with ID "xxx", name "yyy" and no space hard limit + $ stackit beta sfs share create --resource-pool-id xxx --name yyy --hard-limit 0 + + Create a share in a resource pool with ID "xxx", name "yyy" and export policy with name "zzz" + $ stackit beta sfs share create --resource-pool-id xxx --name yyy --export-policy-name zzz --hard-limit 0 +``` + +### Options + +``` + --export-policy-name string The export policy the share is assigned to + --hard-limit int The space hard limit for the share + -h, --help Help for "stackit beta sfs share create" + --name string Share name + --resource-pool-id string The resource pool the share is assigned to +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs share](./stackit_beta_sfs_share.md) - Provides functionality for SFS shares + diff --git a/docs/stackit_beta_sfs_share_delete.md b/docs/stackit_beta_sfs_share_delete.md new file mode 100644 index 000000000..f669a175e --- /dev/null +++ b/docs/stackit_beta_sfs_share_delete.md @@ -0,0 +1,41 @@ +## stackit beta sfs share delete + +Deletes a share + +### Synopsis + +Deletes a share. + +``` +stackit beta sfs share delete SHARE_ID [flags] +``` + +### Examples + +``` + Delete a share with ID "xxx" from a resource pool with ID "yyy" + $ stackit beta sfs share delete xxx --resource-pool-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs share delete" + --resource-pool-id string The resource pool the share is assigned to +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs share](./stackit_beta_sfs_share.md) - Provides functionality for SFS shares + diff --git a/docs/stackit_beta_sfs_share_describe.md b/docs/stackit_beta_sfs_share_describe.md new file mode 100644 index 000000000..7a881501f --- /dev/null +++ b/docs/stackit_beta_sfs_share_describe.md @@ -0,0 +1,41 @@ +## stackit beta sfs share describe + +Shows details of a shares + +### Synopsis + +Shows details of a shares. + +``` +stackit beta sfs share describe SHARE_ID [flags] +``` + +### Examples + +``` + Describe a shares with ID "xxx" from resource pool with ID "yyy" + $ stackit beta sfs export-policy describe xxx --resource-pool-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs share describe" + --resource-pool-id string The resource pool the share is assigned to +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs share](./stackit_beta_sfs_share.md) - Provides functionality for SFS shares + diff --git a/docs/stackit_beta_sfs_share_list.md b/docs/stackit_beta_sfs_share_list.md new file mode 100644 index 000000000..2b3d73461 --- /dev/null +++ b/docs/stackit_beta_sfs_share_list.md @@ -0,0 +1,45 @@ +## stackit beta sfs share list + +Lists all shares of a resource pool + +### Synopsis + +Lists all shares of a resource pool. + +``` +stackit beta sfs share list [flags] +``` + +### Examples + +``` + List all shares from resource pool with ID "xxx" + $ stackit beta sfs export-policy list --resource-pool-id xxx + + List up to 10 shares from resource pool with ID "xxx" + $ stackit beta sfs export-policy list --resource-pool-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs share list" + --limit int Maximum number of entries to list + --resource-pool-id string The resource pool the share is assigned to +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs share](./stackit_beta_sfs_share.md) - Provides functionality for SFS shares + diff --git a/docs/stackit_beta_sfs_share_update.md b/docs/stackit_beta_sfs_share_update.md new file mode 100644 index 000000000..0a0c1f8b8 --- /dev/null +++ b/docs/stackit_beta_sfs_share_update.md @@ -0,0 +1,46 @@ +## stackit beta sfs share update + +Updates a share + +### Synopsis + +Updates a share. + +``` +stackit beta sfs share update SHARE_ID [flags] +``` + +### Examples + +``` + Update share with ID "xxx" with new export-policy-name "yyy" in resource-pool "zzz" + $ stackit beta sfs share update xxx --export-policy-name yyy --resource-pool-id zzz + + Update share with ID "xxx" with new space hard limit "50" in resource-pool "yyy" + $ stackit beta sfs share update xxx --hard-limit 50 --resource-pool-id yyy +``` + +### Options + +``` + --export-policy-name string The export policy the share is assigned to + --hard-limit int The space hard limit for the share + -h, --help Help for "stackit beta sfs share update" + --resource-pool-id string The resource pool the share is assigned to +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs share](./stackit_beta_sfs_share.md) - Provides functionality for SFS shares + diff --git a/docs/stackit_beta_sfs_snapshot.md b/docs/stackit_beta_sfs_snapshot.md new file mode 100644 index 000000000..2c1a31664 --- /dev/null +++ b/docs/stackit_beta_sfs_snapshot.md @@ -0,0 +1,37 @@ +## stackit beta sfs snapshot + +Provides functionality for SFS snapshots + +### Synopsis + +Provides functionality for SFS snapshots. + +``` +stackit beta sfs snapshot [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs snapshot" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs](./stackit_beta_sfs.md) - Provides functionality for SFS (stackit file storage) +* [stackit beta sfs snapshot create](./stackit_beta_sfs_snapshot_create.md) - Creates a new snapshot of a resource pool +* [stackit beta sfs snapshot delete](./stackit_beta_sfs_snapshot_delete.md) - Deletes a snapshot +* [stackit beta sfs snapshot describe](./stackit_beta_sfs_snapshot_describe.md) - Shows details of a snapshot +* [stackit beta sfs snapshot list](./stackit_beta_sfs_snapshot_list.md) - Lists all snapshots of a resource pool + diff --git a/docs/stackit_beta_sfs_snapshot_create.md b/docs/stackit_beta_sfs_snapshot_create.md new file mode 100644 index 000000000..ac7a1988b --- /dev/null +++ b/docs/stackit_beta_sfs_snapshot_create.md @@ -0,0 +1,46 @@ +## stackit beta sfs snapshot create + +Creates a new snapshot of a resource pool + +### Synopsis + +Creates a new snapshot of a resource pool. + +``` +stackit beta sfs snapshot create [flags] +``` + +### Examples + +``` + Create a new snapshot with name "snapshot-name" of a resource pool with ID "xxx" + $ stackit beta sfs snapshot create --name snapshot-name --resource-pool-id xxx + + Create a new snapshot with name "snapshot-name" and comment "snapshot-comment" of a resource pool with ID "xxx" + $ stackit beta sfs snapshot create --name snapshot-name --resource-pool-id xxx --comment "snapshot-comment" +``` + +### Options + +``` + --comment string A comment to add more information to the snapshot + -h, --help Help for "stackit beta sfs snapshot create" + --name string Snapshot name + --resource-pool-id string The resource pool from which the snapshot should be created +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs snapshot](./stackit_beta_sfs_snapshot.md) - Provides functionality for SFS snapshots + diff --git a/docs/stackit_beta_sfs_snapshot_delete.md b/docs/stackit_beta_sfs_snapshot_delete.md new file mode 100644 index 000000000..286d98e28 --- /dev/null +++ b/docs/stackit_beta_sfs_snapshot_delete.md @@ -0,0 +1,41 @@ +## stackit beta sfs snapshot delete + +Deletes a snapshot + +### Synopsis + +Deletes a snapshot. + +``` +stackit beta sfs snapshot delete SNAPSHOT_NAME [flags] +``` + +### Examples + +``` + Delete a snapshot with "SNAPSHOT_NAME" from resource pool with ID "yyy" + $ stackit beta sfs snapshot delete SNAPSHOT_NAME --resource-pool-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs snapshot delete" + --resource-pool-id string The resource pool from which the snapshot should be created +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs snapshot](./stackit_beta_sfs_snapshot.md) - Provides functionality for SFS snapshots + diff --git a/docs/stackit_beta_sfs_snapshot_describe.md b/docs/stackit_beta_sfs_snapshot_describe.md new file mode 100644 index 000000000..a12e42dd7 --- /dev/null +++ b/docs/stackit_beta_sfs_snapshot_describe.md @@ -0,0 +1,41 @@ +## stackit beta sfs snapshot describe + +Shows details of a snapshot + +### Synopsis + +Shows details of a snapshot. + +``` +stackit beta sfs snapshot describe SNAPSHOT_NAME [flags] +``` + +### Examples + +``` + Describe a snapshot with "SNAPSHOT_NAME" from resource pool with ID "yyy" + stackit beta sfs snapshot describe SNAPSHOT_NAME --resource-pool-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs snapshot describe" + --resource-pool-id string The resource pool from which the snapshot should be created +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs snapshot](./stackit_beta_sfs_snapshot.md) - Provides functionality for SFS snapshots + diff --git a/docs/stackit_beta_sfs_snapshot_list.md b/docs/stackit_beta_sfs_snapshot_list.md new file mode 100644 index 000000000..4c8b0c986 --- /dev/null +++ b/docs/stackit_beta_sfs_snapshot_list.md @@ -0,0 +1,42 @@ +## stackit beta sfs snapshot list + +Lists all snapshots of a resource pool + +### Synopsis + +Lists all snapshots of a resource pool. + +``` +stackit beta sfs snapshot list [flags] +``` + +### Examples + +``` + List all snapshots of a resource pool with ID "xxx" + $ stackit beta sfs snapshot list --resource-pool-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit beta sfs snapshot list" + --limit int Number of snapshots to list + --resource-pool-id string The resource pool from which the snapshot should be created +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sfs snapshot](./stackit_beta_sfs_snapshot.md) - Provides functionality for SFS snapshots + diff --git a/docs/stackit_beta_sqlserverflex_instance_create.md b/docs/stackit_beta_sqlserverflex_instance_create.md index 6e8dc16a0..b297bf7b0 100644 --- a/docs/stackit_beta_sqlserverflex_instance_create.md +++ b/docs/stackit_beta_sqlserverflex_instance_create.md @@ -21,7 +21,7 @@ stackit beta sqlserverflex instance create [flags] $ stackit beta sqlserverflex instance create --name my-instance --flavor-id xxx Create a SQLServer Flex instance with name "my-instance", specify flavor by CPU and RAM, set storage size to 20 GB, and restrict access to a specific range of IP addresses. Other parameters are set to default values - $ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4 --storage-size 20 --acl 1.2.3.0/24 + $ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4 --storage-size 20 --acl 1.2.3.0/24 ``` ### Options diff --git a/docs/stackit_beta_sqlserverflex_user_create.md b/docs/stackit_beta_sqlserverflex_user_create.md index f1cf9dbbe..47aa6a5e3 100644 --- a/docs/stackit_beta_sqlserverflex_user_create.md +++ b/docs/stackit_beta_sqlserverflex_user_create.md @@ -9,7 +9,7 @@ Creates a SQLServer Flex user for an instance. 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 beta sqlserverflex user reset-password USER_ID --instance-id INSTANCE_ID -Please refer to https://docs.stackit.cloud/stackit/en/creating-logins-and-users-in-sqlserver-flex-instances-210862358.html for additional information. +Please refer to https://docs.stackit.cloud/products/databases/sqlserver-flex/how-tos/create-logins-and-users-in-sqlserver-flex-instances/ for additional information. The allowed user roles for your instance can be obtained by running: $ stackit beta sqlserverflex options --user-roles --instance-id INSTANCE_ID diff --git a/docs/stackit_config_profile_create.md b/docs/stackit_config_profile_create.md index 595c96fad..abc5004ad 100644 --- a/docs/stackit_config_profile_create.md +++ b/docs/stackit_config_profile_create.md @@ -9,6 +9,7 @@ The profile name can be provided via the STACKIT_CLI_PROFILE environment variabl The environment variable takes precedence over the argument. If you do not want to set the profile as active, use the --no-set flag. If you want to create the new profile with the initial default configurations, use the --empty flag. +If you want to create the new profile and ignore the error for an already existing profile, use the --ignore-existing flag. ``` stackit config profile create PROFILE [flags] @@ -27,9 +28,10 @@ stackit config profile create PROFILE [flags] ### Options ``` - --empty Create the profile with the initial default configurations - -h, --help Help for "stackit config profile create" - --no-set Do not set the profile as the active profile + --empty Create the profile with the initial default configurations + -h, --help Help for "stackit config profile create" + --ignore-existing Suppress the error if the profile exists already. An existing profile will not be modified or overwritten + --no-set Do not set the profile as the active profile ``` ### Options inherited from parent commands diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index b1abf5662..c1f104958 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -31,13 +31,18 @@ stackit config set [flags] ``` --allowed-url-domain string Domain name, used for the verification of the URLs that are given in the custom identity provider endpoint and "STACKIT curl" command --authorization-custom-endpoint string Authorization API base URL, used in calls to this API + --cdn-custom-endpoint string CDN API base URL, used in calls to this API --dns-custom-endpoint string DNS API base URL, used in calls to this API + --edge-custom-endpoint string Edge API base URL, used in calls to this API -h, --help Help for "stackit config set" --iaas-custom-endpoint string IaaS API base URL, used in calls to this API --identity-provider-custom-client-id string Identity Provider client ID, used for user authentication --identity-provider-custom-well-known-configuration string Identity Provider well-known OpenID configuration URL, used for user authentication + --intake-custom-endpoint string Intake API base URL, used in calls to this API + --kms-custom-endpoint string KMS API base URL, used in calls to this API --load-balancer-custom-endpoint string Load Balancer API base URL, used in calls to this API --logme-custom-endpoint string LogMe API base URL, used in calls to this API + --logs-custom-endpoint string Logs API base URL, used in calls to this API --mariadb-custom-endpoint string MariaDB API base URL, used in calls to this API --mongodbflex-custom-endpoint string MongoDB Flex API base URL, used in calls to this API --object-storage-custom-endpoint string Object Storage API base URL, used in calls to this API @@ -53,7 +58,8 @@ stackit config set [flags] --serverbackup-custom-endpoint string Server Backup API base URL, used in calls to this API --service-account-custom-endpoint string Service Account API base URL, used in calls to this API --service-enablement-custom-endpoint string Service Enablement API base URL, used in calls to this API - --session-time-limit string Maximum time before authentication is required again. After this time, you will be prompted to login again to execute commands that require authentication. Can't be larger than 24h. Requires authentication after being set to take effect. Examples: 3h, 5h30m40s (BETA: currently values greater than 2h have no effect) + --session-time-limit string Maximum time before authentication is required again. After this time, you will be prompted to login again to execute commands that require authentication. Can't be larger than 24h. Requires authentication after being set to take effect. Examples: 3h, 5h30m40s + --sfs-custom-endpoint string SFS API base URL, used in calls to this API --ske-custom-endpoint string SKE API base URL, used in calls to this API --sqlserverflex-custom-endpoint string SQLServer Flex API base URL, used in calls to this API --token-custom-endpoint string Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication. diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 4a48b759e..d76f3068c 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -27,15 +27,21 @@ stackit config unset [flags] ``` --allowed-url-domain Domain name, used for the verification of the URLs that are given in the IDP endpoint and curl commands. If unset, defaults to stackit.cloud + --assume-yes If set, skips all confirmation prompts --async Configuration option to run commands asynchronously --authorization-custom-endpoint Authorization API base URL. If unset, uses the default base URL + --cdn-custom-endpoint Custom CDN endpoint URL. If unset, uses the default base URL --dns-custom-endpoint DNS API base URL. If unset, uses the default base URL + --edge-custom-endpoint Edge API base URL. If unset, uses the default base URL -h, --help Help for "stackit config unset" --iaas-custom-endpoint IaaS API base URL. If unset, uses the default base URL --identity-provider-custom-client-id Identity Provider client ID, used for user authentication --identity-provider-custom-well-known-configuration Identity Provider well-known OpenID configuration URL. If unset, uses the default identity provider + --intake-custom-endpoint Intake API base URL. If unset, uses the default base URL + --kms-custom-endpoint KMS API base URL. If unset, uses the default base URL --load-balancer-custom-endpoint Load Balancer API base URL. If unset, uses the default base URL --logme-custom-endpoint LogMe API base URL. If unset, uses the default base URL + --logs-custom-endpoint Logs API base URL. If unset, uses the default base URL --mariadb-custom-endpoint MariaDB API base URL. If unset, uses the default base URL --mongodbflex-custom-endpoint MongoDB Flex API base URL. If unset, uses the default base URL --object-storage-custom-endpoint Object Storage API base URL. If unset, uses the default base URL @@ -54,19 +60,14 @@ stackit config unset [flags] --serverbackup-custom-endpoint Server Backup base URL. If unset, uses the default base URL --service-account-custom-endpoint Service Account API base URL. If unset, uses the default base URL --service-enablement-custom-endpoint Service Enablement API base URL. If unset, uses the default base URL - --session-time-limit Maximum time before authentication is required again. If unset, defaults to 2h + --session-time-limit Maximum time before authentication is required again. If unset, defaults to 12h + --sfs-custom-endpoint SFS API base URL. If unset, uses the default base URL --ske-custom-endpoint SKE API base URL. If unset, uses the default base URL --sqlserverflex-custom-endpoint SQLServer Flex API base URL. If unset, uses the default base URL --token-custom-endpoint Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication. --verbosity Verbosity of the CLI ``` -### Options inherited from parent commands - -``` - -y, --assume-yes If set, skips all confirmation prompts -``` - ### SEE ALSO * [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options diff --git a/docs/stackit_dns_record-set_create.md b/docs/stackit_dns_record-set_create.md index f692b80ad..4f51534ad 100644 --- a/docs/stackit_dns_record-set_create.md +++ b/docs/stackit_dns_record-set_create.md @@ -25,7 +25,7 @@ stackit dns record-set create [flags] --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") + --type string Record type, one of ["A" "AAAA" "SOA" "CNAME" "NS" "MX" "TXT" "SRV" "PTR" "ALIAS" "DNAME" "CAA" "DNSKEY" "DS" "LOC" "NAPTR" "SSHFP" "TLSA" "URI" "CERT" "SVCB" "TYPE" "CSYNC" "HINFO" "HTTPS"] (default "A") --zone-id string Zone ID ``` diff --git a/docs/stackit_dns_zone_create.md b/docs/stackit_dns_zone_create.md index 081628e19..0a0efde1c 100644 --- a/docs/stackit_dns_zone_create.md +++ b/docs/stackit_dns_zone_create.md @@ -36,7 +36,7 @@ stackit dns zone create [flags] --primary strings Primary name server for secondary zone --refresh-time int Refresh time --retry-time int Retry time - --type string Zone type + --type string Zone type, one of: ["primary" "secondary"] ``` ### Options inherited from parent commands diff --git a/docs/stackit_git.md b/docs/stackit_git.md new file mode 100644 index 000000000..2a9b072e2 --- /dev/null +++ b/docs/stackit_git.md @@ -0,0 +1,35 @@ +## stackit git + +Provides functionality for STACKIT Git + +### Synopsis + +Provides functionality for STACKIT Git. + +``` +stackit git [flags] +``` + +### Options + +``` + -h, --help Help for "stackit git" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit git flavor](./stackit_git_flavor.md) - Provides functionality for STACKIT Git flavors +* [stackit git instance](./stackit_git_instance.md) - Provides functionality for STACKIT Git instances + diff --git a/docs/stackit_git_flavor.md b/docs/stackit_git_flavor.md new file mode 100644 index 000000000..c2ec85a08 --- /dev/null +++ b/docs/stackit_git_flavor.md @@ -0,0 +1,34 @@ +## stackit git flavor + +Provides functionality for STACKIT Git flavors + +### Synopsis + +Provides functionality for STACKIT Git flavors. + +``` +stackit git flavor [flags] +``` + +### Options + +``` + -h, --help Help for "stackit git flavor" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit git](./stackit_git.md) - Provides functionality for STACKIT Git +* [stackit git flavor list](./stackit_git_flavor_list.md) - Lists instances flavors of STACKIT Git. + diff --git a/docs/stackit_git_flavor_list.md b/docs/stackit_git_flavor_list.md new file mode 100644 index 000000000..a8fc54b0f --- /dev/null +++ b/docs/stackit_git_flavor_list.md @@ -0,0 +1,44 @@ +## stackit git flavor list + +Lists instances flavors of STACKIT Git. + +### Synopsis + +Lists instances flavors of STACKIT Git for the current project. + +``` +stackit git flavor list [flags] +``` + +### Examples + +``` + List STACKIT Git flavors + $ stackit git flavor list + + Lists up to 10 STACKIT Git flavors + $ stackit git flavor list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit git flavor list" + --limit int Limit the output to the first n elements +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit git flavor](./stackit_git_flavor.md) - Provides functionality for STACKIT Git flavors + diff --git a/docs/stackit_git_instance.md b/docs/stackit_git_instance.md new file mode 100644 index 000000000..5f7c6d243 --- /dev/null +++ b/docs/stackit_git_instance.md @@ -0,0 +1,37 @@ +## stackit git instance + +Provides functionality for STACKIT Git instances + +### Synopsis + +Provides functionality for STACKIT Git instances. + +``` +stackit git instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit git 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit git](./stackit_git.md) - Provides functionality for STACKIT Git +* [stackit git instance create](./stackit_git_instance_create.md) - Creates STACKIT Git instance +* [stackit git instance delete](./stackit_git_instance_delete.md) - Deletes STACKIT Git instance +* [stackit git instance describe](./stackit_git_instance_describe.md) - Describes STACKIT Git instance +* [stackit git instance list](./stackit_git_instance_list.md) - Lists all instances of STACKIT Git. + diff --git a/docs/stackit_git_instance_create.md b/docs/stackit_git_instance_create.md new file mode 100644 index 000000000..a82c92ec4 --- /dev/null +++ b/docs/stackit_git_instance_create.md @@ -0,0 +1,49 @@ +## stackit git instance create + +Creates STACKIT Git instance + +### Synopsis + +Create a STACKIT Git instance by name. + +``` +stackit git instance create [flags] +``` + +### Examples + +``` + Create a instance with name 'my-new-instance' + $ stackit git instance create --name my-new-instance + + Create a instance with name 'my-new-instance' and flavor + $ stackit git instance create --name my-new-instance --flavor git-100 + + Create a instance with name 'my-new-instance' and acl + $ stackit git instance create --name my-new-instance --acl 1.1.1.1/1 +``` + +### Options + +``` + --acl strings Acl for the instance. + --flavor string Flavor of the instance. + -h, --help Help for "stackit git instance create" + --name string The name 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit git instance](./stackit_git_instance.md) - Provides functionality for STACKIT Git instances + diff --git a/docs/stackit_git_instance_delete.md b/docs/stackit_git_instance_delete.md new file mode 100644 index 000000000..df0a6d46a --- /dev/null +++ b/docs/stackit_git_instance_delete.md @@ -0,0 +1,40 @@ +## stackit git instance delete + +Deletes STACKIT Git instance + +### Synopsis + +Deletes a STACKIT Git instance by its internal ID. + +``` +stackit git instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a instance with ID "xxx" + $ stackit git instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit git 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit git instance](./stackit_git_instance.md) - Provides functionality for STACKIT Git instances + diff --git a/docs/stackit_git_instance_describe.md b/docs/stackit_git_instance_describe.md new file mode 100644 index 000000000..90716803e --- /dev/null +++ b/docs/stackit_git_instance_describe.md @@ -0,0 +1,40 @@ +## stackit git instance describe + +Describes STACKIT Git instance + +### Synopsis + +Describes a STACKIT Git instance by its internal ID. + +``` +stackit git instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Describe instance "xxx" + $ stackit git describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit git 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit git instance](./stackit_git_instance.md) - Provides functionality for STACKIT Git instances + diff --git a/docs/stackit_git_instance_list.md b/docs/stackit_git_instance_list.md new file mode 100644 index 000000000..96afe06d4 --- /dev/null +++ b/docs/stackit_git_instance_list.md @@ -0,0 +1,44 @@ +## stackit git instance list + +Lists all instances of STACKIT Git. + +### Synopsis + +Lists all instances of STACKIT Git for the current project. + +``` +stackit git instance list [flags] +``` + +### Examples + +``` + List all STACKIT Git instances + $ stackit git instance list + + Lists up to 10 STACKIT Git instances + $ stackit git instance list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit git instance list" + --limit int Limit the output to the first n elements +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit git instance](./stackit_git_instance.md) - Provides functionality for STACKIT Git instances + diff --git a/docs/stackit_image_create.md b/docs/stackit_image_create.md index 3c608126c..eb8a0a3e9 100644 --- a/docs/stackit_image_create.md +++ b/docs/stackit_image_create.md @@ -18,11 +18,15 @@ stackit image create [flags] Create an image with name 'my-new-image' from a qcow2 image read from '/my/qcow2/image' with labels describing its contents $ stackit image create --name my-new-image --disk-format=qcow2 --local-file-path=/my/qcow2/image --labels os=linux,distro=alpine,version=3.12 + + Create an image with name 'my-new-image' from a raw disk image located in '/my/raw/image' with uefi disabled + $ stackit image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image --uefi=false ``` ### Options ``` + --architecture string Sets the CPU architecture. By default x86 is used. --boot-menu Enables the BIOS bootmenu. --cdrom-bus string Sets CDROM bus controller type. --disk-bus string Sets Disk bus controller type. diff --git a/docs/stackit_image_list.md b/docs/stackit_image_list.md index eae2a3409..28b759eb0 100644 --- a/docs/stackit_image_list.md +++ b/docs/stackit_image_list.md @@ -13,7 +13,7 @@ stackit image list [flags] ### Examples ``` - List all images + List images in your project $ stackit image list List images with label @@ -21,11 +21,15 @@ stackit image list [flags] List the first 10 images $ stackit image list --limit=10 + + List all images + $ stackit image list --all ``` ### Options ``` + --all List all images available -h, --help Help for "stackit image list" --label-selector string Filter by label --limit int Limit the output to the first n elements diff --git a/docs/stackit_image_update.md b/docs/stackit_image_update.md index 3c6acbe62..d088d9962 100644 --- a/docs/stackit_image_update.md +++ b/docs/stackit_image_update.md @@ -23,6 +23,7 @@ stackit image update IMAGE_ID [flags] ### Options ``` + --architecture string Sets the CPU architecture. --boot-menu Enables the BIOS bootmenu. --cdrom-bus string Sets CDROM bus controller type. --disk-bus string Sets Disk bus controller type. diff --git a/docs/stackit_kms.md b/docs/stackit_kms.md new file mode 100644 index 000000000..d1adc5024 --- /dev/null +++ b/docs/stackit_kms.md @@ -0,0 +1,37 @@ +## stackit kms + +Provides functionality for KMS + +### Synopsis + +Provides functionality for KMS. + +``` +stackit kms [flags] +``` + +### Options + +``` + -h, --help Help for "stackit kms" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit kms key](./stackit_kms_key.md) - Manage KMS keys +* [stackit kms keyring](./stackit_kms_keyring.md) - Manage KMS key rings +* [stackit kms version](./stackit_kms_version.md) - Manage KMS key versions +* [stackit kms wrapping-key](./stackit_kms_wrapping-key.md) - Manage KMS wrapping keys + diff --git a/docs/stackit_kms_key.md b/docs/stackit_kms_key.md new file mode 100644 index 000000000..3cdfbdf00 --- /dev/null +++ b/docs/stackit_kms_key.md @@ -0,0 +1,40 @@ +## stackit kms key + +Manage KMS keys + +### Synopsis + +Provides functionality for key operations inside the KMS + +``` +stackit kms key [flags] +``` + +### Options + +``` + -h, --help Help for "stackit kms 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms](./stackit_kms.md) - Provides functionality for KMS +* [stackit kms key create](./stackit_kms_key_create.md) - Creates a KMS key +* [stackit kms key delete](./stackit_kms_key_delete.md) - Deletes a KMS key +* [stackit kms key describe](./stackit_kms_key_describe.md) - Describe a KMS key +* [stackit kms key import](./stackit_kms_key_import.md) - Import a KMS key +* [stackit kms key list](./stackit_kms_key_list.md) - List all KMS keys +* [stackit kms key restore](./stackit_kms_key_restore.md) - Restore a key +* [stackit kms key rotate](./stackit_kms_key_rotate.md) - Rotate a key + diff --git a/docs/stackit_kms_key_create.md b/docs/stackit_kms_key_create.md new file mode 100644 index 000000000..dcf9716f3 --- /dev/null +++ b/docs/stackit_kms_key_create.md @@ -0,0 +1,62 @@ +## stackit kms key create + +Creates a KMS key + +### Synopsis + +Creates a KMS key. + +``` +stackit kms key create [flags] +``` + +### Examples + +``` + Create a symmetric AES key (AES-256) with the name "symm-aes-gcm" under the key ring "my-keyring-id" + $ stackit kms key create --keyring-id "my-keyring-id" --algorithm "aes_256_gcm" --name "symm-aes-gcm" --purpose "symmetric_encrypt_decrypt" --protection "software" + + Create an asymmetric RSA encryption key (RSA-2048) + $ stackit kms key create --keyring-id "my-keyring-id" --algorithm "rsa_2048_oaep_sha256" --name "prod-orders-rsa" --purpose "asymmetric_encrypt_decrypt" --protection "software" + + Create a message authentication key (HMAC-SHA512) + $ stackit kms key create --keyring-id "my-keyring-id" --algorithm "hmac_sha512" --name "api-mac-key" --purpose "message_authentication_code" --protection "software" + + Create an ECDSA P-256 key for signing & verification + $ stackit kms key create --keyring-id "my-keyring-id" --algorithm "ecdsa_p256_sha256" --name "signing-ecdsa-p256" --purpose "asymmetric_sign_verify" --protection "software" + + Create an import-only key (versions must be imported) + $ stackit kms key create --keyring-id "my-keyring-id" --algorithm "rsa_2048_oaep_sha256" --name "ext-managed-rsa" --purpose "asymmetric_encrypt_decrypt" --protection "software" --import-only + + Create a key and print the result as YAML + $ stackit kms key create --keyring-id "my-keyring-id" --algorithm "rsa_2048_oaep_sha256" --name "yaml-output-rsa" --purpose "asymmetric_encrypt_decrypt" --protection "software" --output yaml +``` + +### Options + +``` + --algorithm string En-/Decryption / signing algorithm. Possible values: ["aes_256_gcm" "rsa_2048_oaep_sha256" "rsa_3072_oaep_sha256" "rsa_4096_oaep_sha256" "rsa_4096_oaep_sha512" "hmac_sha256" "hmac_sha384" "hmac_sha512" "ecdsa_p256_sha256" "ecdsa_p384_sha384" "ecdsa_p521_sha512"] + --description string Optional description of the key + -h, --help Help for "stackit kms key create" + --import-only States whether versions can be created or only imported + --keyring-id string ID of the KMS key ring + --name string The display name to distinguish multiple keys + --protection string The underlying system that is responsible for protecting the key material. Possible values: ["symmetric_encrypt_decrypt" "asymmetric_encrypt_decrypt" "message_authentication_code" "asymmetric_sign_verify"] + --purpose string Purpose of the key. Possible values: ["symmetric_encrypt_decrypt" "asymmetric_encrypt_decrypt" "message_authentication_code" "asymmetric_sign_verify"] +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms key](./stackit_kms_key.md) - Manage KMS keys + diff --git a/docs/stackit_kms_key_delete.md b/docs/stackit_kms_key_delete.md new file mode 100644 index 000000000..4a5418843 --- /dev/null +++ b/docs/stackit_kms_key_delete.md @@ -0,0 +1,41 @@ +## stackit kms key delete + +Deletes a KMS key + +### Synopsis + +Deletes a KMS key inside a specific key ring. + +``` +stackit kms key delete KEY_ID [flags] +``` + +### Examples + +``` + Delete a KMS key "MY_KEY_ID" inside the key ring "my-keyring-id" + $ stackit kms key delete "MY_KEY_ID" --keyring-id "my-keyring-id" +``` + +### Options + +``` + -h, --help Help for "stackit kms key delete" + --keyring-id string ID of the KMS key ring where the key is stored +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms key](./stackit_kms_key.md) - Manage KMS keys + diff --git a/docs/stackit_kms_key_describe.md b/docs/stackit_kms_key_describe.md new file mode 100644 index 000000000..d2921c47c --- /dev/null +++ b/docs/stackit_kms_key_describe.md @@ -0,0 +1,41 @@ +## stackit kms key describe + +Describe a KMS key + +### Synopsis + +Describe a KMS key + +``` +stackit kms key describe KEY_ID [flags] +``` + +### Examples + +``` + Describe a KMS key with ID xxx of keyring yyy + $ stackit kms key describe xxx --keyring-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit kms key describe" + --keyring-id string Key Ring 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms key](./stackit_kms_key.md) - Manage KMS keys + diff --git a/docs/stackit_kms_key_import.md b/docs/stackit_kms_key_import.md new file mode 100644 index 000000000..99953dacc --- /dev/null +++ b/docs/stackit_kms_key_import.md @@ -0,0 +1,46 @@ +## stackit kms key import + +Import a KMS key + +### Synopsis + +After encrypting the secret with the wrapping key’s public key and Base64-encoding it, import it as a new version of the specified KMS key. + +``` +stackit kms key import KEY_ID [flags] +``` + +### Examples + +``` + Import a new version for the given KMS key "MY_KEY_ID" from literal value + $ stackit kms key import "MY_KEY_ID" --keyring-id "my-keyring-id" --wrapped-key "BASE64_VALUE" --wrapping-key-id "MY_WRAPPING_KEY_ID" + + Import from a file + $ stackit kms key import "MY_KEY_ID" --keyring-id "my-keyring-id" --wrapped-key "@path/to/wrapped.key.b64" --wrapping-key-id "MY_WRAPPING_KEY_ID" +``` + +### Options + +``` + -h, --help Help for "stackit kms key import" + --keyring-id string ID of the KMS key ring + --wrapped-key string The wrapped key material to be imported. Base64-encoded. Pass the value directly or a file path (e.g. @path/to/wrapped.key.b64) + --wrapping-key-id string The unique id of the wrapping key the key material has been wrapped 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms key](./stackit_kms_key.md) - Manage KMS keys + diff --git a/docs/stackit_kms_key_list.md b/docs/stackit_kms_key_list.md new file mode 100644 index 000000000..336d37a6a --- /dev/null +++ b/docs/stackit_kms_key_list.md @@ -0,0 +1,44 @@ +## stackit kms key list + +List all KMS keys + +### Synopsis + +List all KMS keys inside a key ring. + +``` +stackit kms key list [flags] +``` + +### Examples + +``` + List all KMS keys for the key ring "my-keyring-id" + $ stackit kms key list --keyring-id "my-keyring-id" + + List all KMS keys in JSON format + $ stackit kms key list --keyring-id "my-keyring-id" --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit kms key list" + --keyring-id string ID of the KMS key ring where the key is stored +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms key](./stackit_kms_key.md) - Manage KMS keys + diff --git a/docs/stackit_kms_key_restore.md b/docs/stackit_kms_key_restore.md new file mode 100644 index 000000000..ebe902801 --- /dev/null +++ b/docs/stackit_kms_key_restore.md @@ -0,0 +1,41 @@ +## stackit kms key restore + +Restore a key + +### Synopsis + +Restores the given key from deletion. + +``` +stackit kms key restore KEY_ID [flags] +``` + +### Examples + +``` + Restore a KMS key "MY_KEY_ID" inside the key ring "my-keyring-id" that was scheduled for deletion. + $ stackit kms key restore "MY_KEY_ID" --keyring-id "my-keyring-id" +``` + +### Options + +``` + -h, --help Help for "stackit kms key restore" + --keyring-id string ID of the KMS key ring where the key is stored +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms key](./stackit_kms_key.md) - Manage KMS keys + diff --git a/docs/stackit_kms_key_rotate.md b/docs/stackit_kms_key_rotate.md new file mode 100644 index 000000000..a9152ccf3 --- /dev/null +++ b/docs/stackit_kms_key_rotate.md @@ -0,0 +1,41 @@ +## stackit kms key rotate + +Rotate a key + +### Synopsis + +Rotates the given key. + +``` +stackit kms key rotate KEY_ID [flags] +``` + +### Examples + +``` + Rotate a KMS key "MY_KEY_ID" and increase its version inside the key ring "my-keyring-id". + $ stackit kms key rotate "MY_KEY_ID" --keyring-id "my-keyring-id" +``` + +### Options + +``` + -h, --help Help for "stackit kms key rotate" + --keyring-id string ID of the KMS key ring where the key is stored +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms key](./stackit_kms_key.md) - Manage KMS keys + diff --git a/docs/stackit_kms_keyring.md b/docs/stackit_kms_keyring.md new file mode 100644 index 000000000..00202422e --- /dev/null +++ b/docs/stackit_kms_keyring.md @@ -0,0 +1,37 @@ +## stackit kms keyring + +Manage KMS key rings + +### Synopsis + +Provides functionality for key ring operations inside the KMS + +``` +stackit kms keyring [flags] +``` + +### Options + +``` + -h, --help Help for "stackit kms keyring" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms](./stackit_kms.md) - Provides functionality for KMS +* [stackit kms keyring create](./stackit_kms_keyring_create.md) - Creates a KMS key ring +* [stackit kms keyring delete](./stackit_kms_keyring_delete.md) - Deletes a KMS key ring +* [stackit kms keyring describe](./stackit_kms_keyring_describe.md) - Describe a KMS key ring +* [stackit kms keyring list](./stackit_kms_keyring_list.md) - Lists all KMS key rings + diff --git a/docs/stackit_kms_keyring_create.md b/docs/stackit_kms_keyring_create.md new file mode 100644 index 000000000..e07deb467 --- /dev/null +++ b/docs/stackit_kms_keyring_create.md @@ -0,0 +1,48 @@ +## stackit kms keyring create + +Creates a KMS key ring + +### Synopsis + +Creates a KMS key ring. + +``` +stackit kms keyring create [flags] +``` + +### Examples + +``` + Create a KMS key ring with name "my-keyring" + $ stackit kms keyring create --name my-keyring + + Create a KMS key ring with a description + $ stackit kms keyring create --name my-keyring --description my-description + + Create a KMS key ring and print the result as YAML + $ stackit kms keyring create --name my-keyring -o yaml +``` + +### Options + +``` + --description string Optional description of the key ring + -h, --help Help for "stackit kms keyring create" + --name string Name of the KMS key ring +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms keyring](./stackit_kms_keyring.md) - Manage KMS key rings + diff --git a/docs/stackit_kms_keyring_delete.md b/docs/stackit_kms_keyring_delete.md new file mode 100644 index 000000000..e60c0b44a --- /dev/null +++ b/docs/stackit_kms_keyring_delete.md @@ -0,0 +1,40 @@ +## stackit kms keyring delete + +Deletes a KMS key ring + +### Synopsis + +Deletes a KMS key ring. + +``` +stackit kms keyring delete KEYRING-ID [flags] +``` + +### Examples + +``` + Delete a KMS key ring with ID "MY_KEYRING_ID" + $ stackit kms keyring delete "MY_KEYRING_ID" +``` + +### Options + +``` + -h, --help Help for "stackit kms keyring 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms keyring](./stackit_kms_keyring.md) - Manage KMS key rings + diff --git a/docs/stackit_kms_keyring_describe.md b/docs/stackit_kms_keyring_describe.md new file mode 100644 index 000000000..f36bf9030 --- /dev/null +++ b/docs/stackit_kms_keyring_describe.md @@ -0,0 +1,40 @@ +## stackit kms keyring describe + +Describe a KMS key ring + +### Synopsis + +Describe a KMS key ring + +``` +stackit kms keyring describe KEYRING_ID [flags] +``` + +### Examples + +``` + Describe a KMS key ring with ID xxx + $ stackit kms keyring describe xxx +``` + +### Options + +``` + -h, --help Help for "stackit kms keyring 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms keyring](./stackit_kms_keyring.md) - Manage KMS key rings + diff --git a/docs/stackit_kms_keyring_list.md b/docs/stackit_kms_keyring_list.md new file mode 100644 index 000000000..65f3ab323 --- /dev/null +++ b/docs/stackit_kms_keyring_list.md @@ -0,0 +1,43 @@ +## stackit kms keyring list + +Lists all KMS key rings + +### Synopsis + +Lists all KMS key rings. + +``` +stackit kms keyring list [flags] +``` + +### Examples + +``` + List all KMS key rings + $ stackit kms keyring list + + List all KMS key rings in JSON format + $ stackit kms keyring list --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit kms keyring 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms keyring](./stackit_kms_keyring.md) - Manage KMS key rings + diff --git a/docs/stackit_kms_version.md b/docs/stackit_kms_version.md new file mode 100644 index 000000000..06b3f23b5 --- /dev/null +++ b/docs/stackit_kms_version.md @@ -0,0 +1,38 @@ +## stackit kms version + +Manage KMS key versions + +### Synopsis + +Provides functionality for key version operations inside the KMS + +``` +stackit kms version [flags] +``` + +### Options + +``` + -h, --help Help for "stackit kms 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms](./stackit_kms.md) - Provides functionality for KMS +* [stackit kms version destroy](./stackit_kms_version_destroy.md) - Destroy a key version +* [stackit kms version disable](./stackit_kms_version_disable.md) - Disable a key version +* [stackit kms version enable](./stackit_kms_version_enable.md) - Enable a key version +* [stackit kms version list](./stackit_kms_version_list.md) - List all key versions +* [stackit kms version restore](./stackit_kms_version_restore.md) - Restore a key version + diff --git a/docs/stackit_kms_version_destroy.md b/docs/stackit_kms_version_destroy.md new file mode 100644 index 000000000..3145408c1 --- /dev/null +++ b/docs/stackit_kms_version_destroy.md @@ -0,0 +1,42 @@ +## stackit kms version destroy + +Destroy a key version + +### Synopsis + +Removes the key material of a version. + +``` +stackit kms version destroy VERSION_NUMBER [flags] +``` + +### Examples + +``` + Destroy key version "42" for the key "my-key-id" inside the key ring "my-keyring-id" + $ stackit kms version destroy 42 --key-id "my-key-id" --keyring-id "my-keyring-id" +``` + +### Options + +``` + -h, --help Help for "stackit kms version destroy" + --key-id string ID of the key + --keyring-id string ID of the KMS key ring +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms version](./stackit_kms_version.md) - Manage KMS key versions + diff --git a/docs/stackit_kms_version_disable.md b/docs/stackit_kms_version_disable.md new file mode 100644 index 000000000..0239dd4c1 --- /dev/null +++ b/docs/stackit_kms_version_disable.md @@ -0,0 +1,42 @@ +## stackit kms version disable + +Disable a key version + +### Synopsis + +Disable the given key version. + +``` +stackit kms version disable VERSION_NUMBER [flags] +``` + +### Examples + +``` + Disable key version "42" for the key "my-key-id" inside the key ring "my-keyring-id" + $ stackit kms version disable 42 --key-id "my-key-id" --keyring-id "my-keyring-id" +``` + +### Options + +``` + -h, --help Help for "stackit kms version disable" + --key-id string ID of the key + --keyring-id string ID of the KMS key ring +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms version](./stackit_kms_version.md) - Manage KMS key versions + diff --git a/docs/stackit_kms_version_enable.md b/docs/stackit_kms_version_enable.md new file mode 100644 index 000000000..bdb59e5d5 --- /dev/null +++ b/docs/stackit_kms_version_enable.md @@ -0,0 +1,42 @@ +## stackit kms version enable + +Enable a key version + +### Synopsis + +Enable the given key version. + +``` +stackit kms version enable VERSION_NUMBER [flags] +``` + +### Examples + +``` + Enable key version "42" for the key "my-key-id" inside the key ring "my-keyring-id" + $ stackit kms version enable 42 --key-id "my-key-id" --keyring-id "my-keyring-id" +``` + +### Options + +``` + -h, --help Help for "stackit kms version enable" + --key-id string ID of the key + --keyring-id string ID of the KMS key ring +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms version](./stackit_kms_version.md) - Manage KMS key versions + diff --git a/docs/stackit_kms_version_list.md b/docs/stackit_kms_version_list.md new file mode 100644 index 000000000..15522ab18 --- /dev/null +++ b/docs/stackit_kms_version_list.md @@ -0,0 +1,45 @@ +## stackit kms version list + +List all key versions + +### Synopsis + +List all versions of a given key. + +``` +stackit kms version list [flags] +``` + +### Examples + +``` + List all key versions for the key "my-key-id" inside the key ring "my-keyring-id" + $ stackit kms version list --key-id "my-key-id" --keyring-id "my-keyring-id" + + List all key versions in JSON format + $ stackit kms version list --key-id "my-key-id" --keyring-id "my-keyring-id" -o json +``` + +### Options + +``` + -h, --help Help for "stackit kms version list" + --key-id string ID of the key + --keyring-id string ID of the KMS key ring +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms version](./stackit_kms_version.md) - Manage KMS key versions + diff --git a/docs/stackit_kms_version_restore.md b/docs/stackit_kms_version_restore.md new file mode 100644 index 000000000..b2bb1519c --- /dev/null +++ b/docs/stackit_kms_version_restore.md @@ -0,0 +1,42 @@ +## stackit kms version restore + +Restore a key version + +### Synopsis + +Restores the specified version of a key. + +``` +stackit kms version restore VERSION_NUMBER [flags] +``` + +### Examples + +``` + Restore key version "42" for the key "my-key-id" inside the key ring "my-keyring-id" + $ stackit kms version restore 42 --key-id "my-key-id" --keyring-id "my-keyring-id" +``` + +### Options + +``` + -h, --help Help for "stackit kms version restore" + --key-id string ID of the key + --keyring-id string ID of the KMS key ring +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms version](./stackit_kms_version.md) - Manage KMS key versions + diff --git a/docs/stackit_kms_wrapping-key.md b/docs/stackit_kms_wrapping-key.md new file mode 100644 index 000000000..eec6e11a5 --- /dev/null +++ b/docs/stackit_kms_wrapping-key.md @@ -0,0 +1,37 @@ +## stackit kms wrapping-key + +Manage KMS wrapping keys + +### Synopsis + +Provides functionality for wrapping key operations inside the KMS + +``` +stackit kms wrapping-key [flags] +``` + +### Options + +``` + -h, --help Help for "stackit kms wrapping-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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms](./stackit_kms.md) - Provides functionality for KMS +* [stackit kms wrapping-key create](./stackit_kms_wrapping-key_create.md) - Creates a KMS wrapping key +* [stackit kms wrapping-key delete](./stackit_kms_wrapping-key_delete.md) - Deletes a KMS wrapping key +* [stackit kms wrapping-key describe](./stackit_kms_wrapping-key_describe.md) - Describe a KMS wrapping key +* [stackit kms wrapping-key list](./stackit_kms_wrapping-key_list.md) - Lists all KMS wrapping keys + diff --git a/docs/stackit_kms_wrapping-key_create.md b/docs/stackit_kms_wrapping-key_create.md new file mode 100644 index 000000000..616f60ac7 --- /dev/null +++ b/docs/stackit_kms_wrapping-key_create.md @@ -0,0 +1,49 @@ +## stackit kms wrapping-key create + +Creates a KMS wrapping key + +### Synopsis + +Creates a KMS wrapping key. + +``` +stackit kms wrapping-key create [flags] +``` + +### Examples + +``` + Create a symmetric (RSA + AES) KMS wrapping key with name "my-wrapping-key-name" in key ring with ID "my-keyring-id" + $ stackit kms wrapping-key create --keyring-id "my-keyring-id" --algorithm "rsa_2048_oaep_sha256_aes_256_key_wrap" --name "my-wrapping-key-name" --purpose "wrap_symmetric_key" --protection "software" + + Create an asymmetric (RSA) KMS wrapping key with name "my-wrapping-key-name" in key ring with ID "my-keyring-id" + $ stackit kms wrapping-key create --keyring-id "my-keyring-id" --algorithm "rsa_3072_oaep_sha256" --name "my-wrapping-key-name" --purpose "wrap_asymmetric_key" --protection "software" +``` + +### Options + +``` + --algorithm string En-/Decryption / signing algorithm. Possible values: ["rsa_2048_oaep_sha256" "rsa_3072_oaep_sha256" "rsa_4096_oaep_sha256" "rsa_4096_oaep_sha512" "rsa_2048_oaep_sha256_aes_256_key_wrap" "rsa_3072_oaep_sha256_aes_256_key_wrap" "rsa_4096_oaep_sha256_aes_256_key_wrap" "rsa_4096_oaep_sha512_aes_256_key_wrap"] + --description string Optional description of the wrapping key + -h, --help Help for "stackit kms wrapping-key create" + --keyring-id string ID of the KMS key ring + --name string The display name to distinguish multiple wrapping keys + --protection string The underlying system that is responsible for protecting the wrapping key material. Possible values: ["wrap_symmetric_key" "wrap_asymmetric_key"] + --purpose string Purpose of the wrapping key. Possible values: ["wrap_symmetric_key" "wrap_asymmetric_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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms wrapping-key](./stackit_kms_wrapping-key.md) - Manage KMS wrapping keys + diff --git a/docs/stackit_kms_wrapping-key_delete.md b/docs/stackit_kms_wrapping-key_delete.md new file mode 100644 index 000000000..7504c1782 --- /dev/null +++ b/docs/stackit_kms_wrapping-key_delete.md @@ -0,0 +1,41 @@ +## stackit kms wrapping-key delete + +Deletes a KMS wrapping key + +### Synopsis + +Deletes a KMS wrapping key inside a specific key ring. + +``` +stackit kms wrapping-key delete WRAPPING_KEY_ID [flags] +``` + +### Examples + +``` + Delete a KMS wrapping key "MY_WRAPPING_KEY_ID" inside the key ring "my-keyring-id" + $ stackit kms wrapping-key delete "MY_WRAPPING_KEY_ID" --keyring-id "my-keyring-id" +``` + +### Options + +``` + -h, --help Help for "stackit kms wrapping-key delete" + --keyring-id string ID of the KMS key ring where the wrapping key is stored +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms wrapping-key](./stackit_kms_wrapping-key.md) - Manage KMS wrapping keys + diff --git a/docs/stackit_kms_wrapping-key_describe.md b/docs/stackit_kms_wrapping-key_describe.md new file mode 100644 index 000000000..2d1f484b4 --- /dev/null +++ b/docs/stackit_kms_wrapping-key_describe.md @@ -0,0 +1,41 @@ +## stackit kms wrapping-key describe + +Describe a KMS wrapping key + +### Synopsis + +Describe a KMS wrapping key + +``` +stackit kms wrapping-key describe WRAPPING_KEY_ID [flags] +``` + +### Examples + +``` + Describe a KMS wrapping key with ID xxx of keyring yyy + $ stackit kms wrapping-key describe xxx --keyring-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit kms wrapping-key describe" + --keyring-id string Key Ring 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms wrapping-key](./stackit_kms_wrapping-key.md) - Manage KMS wrapping keys + diff --git a/docs/stackit_kms_wrapping-key_list.md b/docs/stackit_kms_wrapping-key_list.md new file mode 100644 index 000000000..bc9d5dce0 --- /dev/null +++ b/docs/stackit_kms_wrapping-key_list.md @@ -0,0 +1,44 @@ +## stackit kms wrapping-key list + +Lists all KMS wrapping keys + +### Synopsis + +Lists all KMS wrapping keys inside a key ring. + +``` +stackit kms wrapping-key list [flags] +``` + +### Examples + +``` + List all KMS wrapping keys for the key ring "my-keyring-id" + $ stackit kms wrapping-key list --keyring-id "my-keyring-id" + + List all KMS wrapping keys in JSON format + $ stackit kms wrapping-key list --keyring-id "my-keyring-id" --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit kms wrapping-key list" + --keyring-id string ID of the KMS key ring where the key is stored +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit kms wrapping-key](./stackit_kms_wrapping-key.md) - Manage KMS wrapping keys + diff --git a/docs/stackit_logs.md b/docs/stackit_logs.md new file mode 100644 index 000000000..f01dc7d0d --- /dev/null +++ b/docs/stackit_logs.md @@ -0,0 +1,35 @@ +## stackit logs + +Provides functionality for Logs + +### Synopsis + +Provides functionality for Logs. + +``` +stackit logs [flags] +``` + +### Options + +``` + -h, --help Help for "stackit logs" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit logs access-token](./stackit_logs_access-token.md) - Provides functionality for Logs access-tokens +* [stackit logs instance](./stackit_logs_instance.md) - Provides functionality for Logs instances + diff --git a/docs/stackit_logs_access-token.md b/docs/stackit_logs_access-token.md new file mode 100644 index 000000000..a42baa613 --- /dev/null +++ b/docs/stackit_logs_access-token.md @@ -0,0 +1,40 @@ +## stackit logs access-token + +Provides functionality for Logs access-tokens + +### Synopsis + +Provides functionality for Logs access-tokens. + +``` +stackit logs access-token [flags] +``` + +### Options + +``` + -h, --help Help for "stackit logs access-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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs](./stackit_logs.md) - Provides functionality for Logs +* [stackit logs access-token create](./stackit_logs_access-token_create.md) - Creates a Logs access token +* [stackit logs access-token delete](./stackit_logs_access-token_delete.md) - Deletes a Logs access token +* [stackit logs access-token delete-all](./stackit_logs_access-token_delete-all.md) - Deletes all Logs access token +* [stackit logs access-token delete-all-expired](./stackit_logs_access-token_delete-all-expired.md) - Deletes all expired Logs access token +* [stackit logs access-token describe](./stackit_logs_access-token_describe.md) - Shows details of a Logs access token +* [stackit logs access-token list](./stackit_logs_access-token_list.md) - Lists all Logs access tokens of a project +* [stackit logs access-token update](./stackit_logs_access-token_update.md) - Updates a Logs access token + diff --git a/docs/stackit_logs_access-token_create.md b/docs/stackit_logs_access-token_create.md new file mode 100644 index 000000000..947260d03 --- /dev/null +++ b/docs/stackit_logs_access-token_create.md @@ -0,0 +1,51 @@ +## stackit logs access-token create + +Creates a Logs access token + +### Synopsis + +Creates a Logs access token. + +``` +stackit logs access-token create [flags] +``` + +### Examples + +``` + Create a access token with the display name "access-token-1" for the instance "xxx" with read and write permissions + $ stackit logs access-token create --display-name access-token-1 --instance-id xxx --permissions read,write + + Create a write only access token with a description + $ stackit logs access-token create --display-name access-token-2 --instance-id xxx --permissions write --description "Access token for service" + + Create a read only access token which expires in 30 days + $ stackit logs access-token create --display-name access-token-3 --instance-id xxx --permissions read --lifetime 30 +``` + +### Options + +``` + --description string Description of the access token + --display-name string Display name for the access token + -h, --help Help for "stackit logs access-token create" + --instance-id string ID of the Logs instance + --lifetime int Lifetime of the access token in days [1 - 180] + --permissions strings Permissions of the access token ["read" "write"] +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs access-token](./stackit_logs_access-token.md) - Provides functionality for Logs access-tokens + diff --git a/docs/stackit_logs_access-token_delete-all-expired.md b/docs/stackit_logs_access-token_delete-all-expired.md new file mode 100644 index 000000000..b63792e63 --- /dev/null +++ b/docs/stackit_logs_access-token_delete-all-expired.md @@ -0,0 +1,41 @@ +## stackit logs access-token delete-all-expired + +Deletes all expired Logs access token + +### Synopsis + +Deletes all expired Logs access token. + +``` +stackit logs access-token delete-all-expired [flags] +``` + +### Examples + +``` + Delete all expired access tokens in instance "xxx" + $ stackit logs access-token delete-all-expired --instance-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit logs access-token delete-all-expired" + --instance-id string ID of the Logs 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs access-token](./stackit_logs_access-token.md) - Provides functionality for Logs access-tokens + diff --git a/docs/stackit_logs_access-token_delete-all.md b/docs/stackit_logs_access-token_delete-all.md new file mode 100644 index 000000000..de546fdef --- /dev/null +++ b/docs/stackit_logs_access-token_delete-all.md @@ -0,0 +1,41 @@ +## stackit logs access-token delete-all + +Deletes all Logs access token + +### Synopsis + +Deletes all Logs access token. + +``` +stackit logs access-token delete-all [flags] +``` + +### Examples + +``` + Delete all access tokens in instance "xxx" + $ stackit logs access-token delete-all --instance-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit logs access-token delete-all" + --instance-id string ID of the Logs 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs access-token](./stackit_logs_access-token.md) - Provides functionality for Logs access-tokens + diff --git a/docs/stackit_logs_access-token_delete.md b/docs/stackit_logs_access-token_delete.md new file mode 100644 index 000000000..99d700c81 --- /dev/null +++ b/docs/stackit_logs_access-token_delete.md @@ -0,0 +1,41 @@ +## stackit logs access-token delete + +Deletes a Logs access token + +### Synopsis + +Deletes a Logs access token. + +``` +stackit logs access-token delete ACCESS_TOKEN_ID [flags] +``` + +### Examples + +``` + Delete access token with ID "xxx" in instance "yyy" + $ stackit logs access-token delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit logs access-token delete" + --instance-id string ID of the Logs 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs access-token](./stackit_logs_access-token.md) - Provides functionality for Logs access-tokens + diff --git a/docs/stackit_logs_access-token_describe.md b/docs/stackit_logs_access-token_describe.md new file mode 100644 index 000000000..1e690bcda --- /dev/null +++ b/docs/stackit_logs_access-token_describe.md @@ -0,0 +1,44 @@ +## stackit logs access-token describe + +Shows details of a Logs access token + +### Synopsis + +Shows details of a Logs access token. + +``` +stackit logs access-token describe ACCESS_TOKEN_ID [flags] +``` + +### Examples + +``` + Show details of a Logs access token with ID "xxx" + $ stackit logs access-token describe xxx + + Show details of a Logs access token with ID "xxx" in JSON format + $ stackit logs access-token describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit logs access-token describe" + --instance-id string ID of the Logs 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs access-token](./stackit_logs_access-token.md) - Provides functionality for Logs access-tokens + diff --git a/docs/stackit_logs_access-token_list.md b/docs/stackit_logs_access-token_list.md new file mode 100644 index 000000000..0fc6b899e --- /dev/null +++ b/docs/stackit_logs_access-token_list.md @@ -0,0 +1,48 @@ +## stackit logs access-token list + +Lists all Logs access tokens of a project + +### Synopsis + +Lists all access tokens of a project. + +``` +stackit logs access-token list [flags] +``` + +### Examples + +``` + Lists all access tokens of the instance "xxx" + $ stackit logs access-token list --instance-id xxx + + Lists all access tokens in JSON format + $ stackit logs access-token list --instance-id xxx --output-format json + + Lists up to 10 access-token + $ stackit logs access-token list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit logs access-token list" + --instance-id string ID of the Logs instance + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs access-token](./stackit_logs_access-token.md) - Provides functionality for Logs access-tokens + diff --git a/docs/stackit_logs_access-token_update.md b/docs/stackit_logs_access-token_update.md new file mode 100644 index 000000000..10bc62701 --- /dev/null +++ b/docs/stackit_logs_access-token_update.md @@ -0,0 +1,46 @@ +## stackit logs access-token update + +Updates a Logs access token + +### Synopsis + +Updates a access token. + +``` +stackit logs access-token update ACCESS_TOKEN_ID [flags] +``` + +### Examples + +``` + Update access token with ID "xxx" with new name "access-token-1" + $ stackit logs access-token update xxx --instance-id yyy --display-name access-token-1 + + Update access token with ID "xxx" with new description "Access token for Service XY" + $ stackit logs access-token update xxx --instance-id yyy --description "Access token for Service XY" +``` + +### Options + +``` + --description string Description of the access token + --display-name string Display name for the access token + -h, --help Help for "stackit logs access-token update" + --instance-id string ID of the Logs 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs access-token](./stackit_logs_access-token.md) - Provides functionality for Logs access-tokens + diff --git a/docs/stackit_logs_instance.md b/docs/stackit_logs_instance.md new file mode 100644 index 000000000..546eb96b6 --- /dev/null +++ b/docs/stackit_logs_instance.md @@ -0,0 +1,38 @@ +## stackit logs instance + +Provides functionality for Logs instances + +### Synopsis + +Provides functionality for Logs instances. + +``` +stackit logs instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit logs 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs](./stackit_logs.md) - Provides functionality for Logs +* [stackit logs instance create](./stackit_logs_instance_create.md) - Creates a Logs instance +* [stackit logs instance delete](./stackit_logs_instance_delete.md) - Deletes the given Logs instance +* [stackit logs instance describe](./stackit_logs_instance_describe.md) - Shows details of a Logs instance +* [stackit logs instance list](./stackit_logs_instance_list.md) - Lists Logs instances +* [stackit logs instance update](./stackit_logs_instance_update.md) - Updates a Logs instance + diff --git a/docs/stackit_logs_instance_create.md b/docs/stackit_logs_instance_create.md new file mode 100644 index 000000000..e68a37824 --- /dev/null +++ b/docs/stackit_logs_instance_create.md @@ -0,0 +1,50 @@ +## stackit logs instance create + +Creates a Logs instance + +### Synopsis + +Creates a Logs instance. + +``` +stackit logs instance create [flags] +``` + +### Examples + +``` + Create a Logs instance with name "my-instance" and retention time 10 days + $ stackit logs instance create --display-name "my-instance" --retention-days 10 + + Create a Logs instance with name "my-instance", retention time 10 days, and a description + $ stackit logs instance create --display-name "my-instance" --retention-days 10 --description "Description of the instance" + + Create a Logs instance with name "my-instance", retention time 10 days, and restrict access to a specific range of IP addresses. + $ stackit logs instance create --display-name "my-instance" --retention-days 10 --acl 1.2.3.0/24 +``` + +### Options + +``` + --acl strings Access control list + --description string Description + --display-name string Display name + -h, --help Help for "stackit logs instance create" + --retention-days int The days for how long the logs should be stored before being cleaned up +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs instance](./stackit_logs_instance.md) - Provides functionality for Logs instances + diff --git a/docs/stackit_logs_instance_delete.md b/docs/stackit_logs_instance_delete.md new file mode 100644 index 000000000..30eecdb7c --- /dev/null +++ b/docs/stackit_logs_instance_delete.md @@ -0,0 +1,40 @@ +## stackit logs instance delete + +Deletes the given Logs instance + +### Synopsis + +Deletes the given Logs instance. + +``` +stackit logs instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a Logs instance with ID "xxx" + $ stackit logs instance delete "xxx" +``` + +### Options + +``` + -h, --help Help for "stackit logs 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs instance](./stackit_logs_instance.md) - Provides functionality for Logs instances + diff --git a/docs/stackit_logs_instance_describe.md b/docs/stackit_logs_instance_describe.md new file mode 100644 index 000000000..f35dfc430 --- /dev/null +++ b/docs/stackit_logs_instance_describe.md @@ -0,0 +1,43 @@ +## stackit logs instance describe + +Shows details of a Logs instance + +### Synopsis + +Shows details of a Logs instance + +``` +stackit logs instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of a Logs instance with ID "xxx" + $ stackit logs instance describe xxx + + Get details of a Logs instance with ID "xxx" in JSON format + $ stackit logs instance describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit logs 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs instance](./stackit_logs_instance.md) - Provides functionality for Logs instances + diff --git a/docs/stackit_logs_instance_list.md b/docs/stackit_logs_instance_list.md new file mode 100644 index 000000000..4a21422bb --- /dev/null +++ b/docs/stackit_logs_instance_list.md @@ -0,0 +1,44 @@ +## stackit logs instance list + +Lists Logs instances + +### Synopsis + +Lists Logs instances within the project. + +``` +stackit logs instance list [flags] +``` + +### Examples + +``` + List all Logs instances + $ stackit logs instance list + + List the first 10 Logs instances + $ stackit logs instance list --limit=10 +``` + +### Options + +``` + -h, --help Help for "stackit logs instance list" + --limit int Limit the output to the first n elements +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs instance](./stackit_logs_instance.md) - Provides functionality for Logs instances + diff --git a/docs/stackit_logs_instance_update.md b/docs/stackit_logs_instance_update.md new file mode 100644 index 000000000..a4fdd5a33 --- /dev/null +++ b/docs/stackit_logs_instance_update.md @@ -0,0 +1,50 @@ +## stackit logs instance update + +Updates a Logs instance + +### Synopsis + +Updates a Logs instance. + +``` +stackit logs instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the display name of the Logs instance with ID "xxx" + $ stackit logs instance update xxx --display-name new-name + + Update the retention time of the Logs instance with ID "xxx" + $ stackit logs instance update xxx --retention-days 40 + + Update the ACL of the Logs instance with ID "xxx" + $ stackit logs instance update xxx --acl 1.2.3.0/24 +``` + +### Options + +``` + --acl strings Access control list + --description string Description + --display-name string Display name + -h, --help Help for "stackit logs instance update" + --retention-days int The days for how long the logs should be stored before being cleaned up +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit logs instance](./stackit_logs_instance.md) - Provides functionality for Logs instances + diff --git a/docs/stackit_mongodbflex_user_create.md b/docs/stackit_mongodbflex_user_create.md index 8c42b3bb2..e8d4cdace 100644 --- a/docs/stackit_mongodbflex_user_create.md +++ b/docs/stackit_mongodbflex_user_create.md @@ -29,7 +29,7 @@ stackit mongodbflex user create [flags] --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 - --role strings Roles of the user, possible values are ["read" "readWrite"] (default [read]) + --role strings Roles of the user, possible values are ["read" "readWrite" "readAnyDatabase" "readWriteAnyDatabase" "stackitAdmin"]. The "readAnyDatabase", "readWriteAnyDatabase" and "stackitAdmin" roles will always be created in the admin database. (default [read]) --username string Username of the user. If not specified, a random username will be assigned ``` diff --git a/docs/stackit_mongodbflex_user_update.md b/docs/stackit_mongodbflex_user_update.md index bcbdfa54f..02e0d42af 100644 --- a/docs/stackit_mongodbflex_user_update.md +++ b/docs/stackit_mongodbflex_user_update.md @@ -23,7 +23,7 @@ stackit mongodbflex user update USER_ID [flags] --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 - --role strings Roles of the user, possible values are ["read" "readWrite"] (default []) + --role strings Roles of the user, possible values are ["read" "readWrite" "readAnyDatabase" "readWriteAnyDatabase" "stackitAdmin"]. The "readAnyDatabase", "readWriteAnyDatabase" and "stackitAdmin" roles will always be created in the admin database. (default []) ``` ### Options inherited from parent commands diff --git a/docs/stackit_network-area.md b/docs/stackit_network-area.md index d9ba1ecda..e99e52ad1 100644 --- a/docs/stackit_network-area.md +++ b/docs/stackit_network-area.md @@ -35,6 +35,8 @@ stackit network-area [flags] * [stackit network-area describe](./stackit_network-area_describe.md) - Shows details of a STACKIT Network Area * [stackit network-area list](./stackit_network-area_list.md) - Lists all STACKIT Network Areas (SNA) of an organization * [stackit network-area network-range](./stackit_network-area_network-range.md) - Provides functionality for network ranges in STACKIT Network Areas +* [stackit network-area region](./stackit_network-area_region.md) - Provides functionality for regional configuration of STACKIT Network Area (SNA) * [stackit network-area route](./stackit_network-area_route.md) - Provides functionality for static routes in STACKIT Network Areas +* [stackit network-area routing-table](./stackit_network-area_routing-table.md) - Manage routing-tables and its according routes * [stackit network-area update](./stackit_network-area_update.md) - Updates a STACKIT Network Area (SNA) diff --git a/docs/stackit_network-area_create.md b/docs/stackit_network-area_create.md index 7dc278927..e9a28231d 100644 --- a/docs/stackit_network-area_create.md +++ b/docs/stackit_network-area_create.md @@ -13,32 +13,20 @@ stackit network-area create [flags] ### Examples ``` - Create a network area with name "network-area-1" in organization with ID "xxx" with network ranges and a transfer network - $ stackit network-area create --name network-area-1 --organization-id xxx --network-ranges "1.1.1.0/24,192.123.1.0/24" --transfer-network "192.160.0.0/24" + Create a network area with name "network-area-1" in organization with ID "xxx" + $ stackit network-area create --name network-area-1 --organization-id xxx" - Create a network area with name "network-area-2" in organization with ID "xxx" with network ranges, transfer network and DNS name server - $ stackit network-area create --name network-area-2 --organization-id xxx --network-ranges "1.1.1.0/24,192.123.1.0/24" --transfer-network "192.160.0.0/24" --dns-name-servers "1.1.1.1" - - Create a network area with name "network-area-3" in organization with ID "xxx" with network ranges, transfer network and additional options - $ stackit network-area create --name network-area-3 --organization-id xxx --network-ranges "1.1.1.0/24,192.123.1.0/24" --transfer-network "192.160.0.0/24" --default-prefix-length 25 --max-prefix-length 29 --min-prefix-length 24 - - Create a network area with name "network-area-1" in organization with ID "xxx" with network ranges and a transfer network and labels "key=value,key1=value1" - $ stackit network-area create --name network-area-1 --organization-id xxx --network-ranges "1.1.1.0/24,192.123.1.0/24" --transfer-network "192.160.0.0/24" --labels key=value,key1=value1 + Create a network area with name "network-area-1" in organization with ID "xxx" with labels "key=value,key1=value1" + $ stackit network-area create --name network-area-1 --organization-id xxx --labels key=value,key1=value1 ``` ### Options ``` - --default-prefix-length int The default prefix length for networks in the network area - --dns-name-servers strings List of DNS name server IPs - -h, --help Help for "stackit network-area create" - --labels stringToString Labels are key-value string pairs which can be attached to a network-area. E.g. '--labels key1=value1,key2=value2,...' (default []) - --max-prefix-length int The maximum prefix length for networks in the network area - --min-prefix-length int The minimum prefix length for networks in the network area - -n, --name string Network area name - --network-ranges strings List of network ranges (default []) - --organization-id string Organization ID - --transfer-network string Transfer network in CIDR notation + -h, --help Help for "stackit network-area create" + --labels stringToString Labels are key-value string pairs which can be attached to a network-area. E.g. '--labels key1=value1,key2=value2,...' (default []) + -n, --name string Network area name + --organization-id string Organization ID ``` ### Options inherited from parent commands diff --git a/docs/stackit_network-area_region.md b/docs/stackit_network-area_region.md new file mode 100644 index 000000000..07fd820eb --- /dev/null +++ b/docs/stackit_network-area_region.md @@ -0,0 +1,38 @@ +## stackit network-area region + +Provides functionality for regional configuration of STACKIT Network Area (SNA) + +### Synopsis + +Provides functionality for regional configuration of STACKIT Network Area (SNA). + +``` +stackit network-area region [flags] +``` + +### Options + +``` + -h, --help Help for "stackit network-area region" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area](./stackit_network-area.md) - Provides functionality for STACKIT Network Area (SNA) +* [stackit network-area region create](./stackit_network-area_region_create.md) - Creates a new regional configuration for a STACKIT Network Area (SNA) +* [stackit network-area region delete](./stackit_network-area_region_delete.md) - Deletes a regional configuration for a STACKIT Network Area (SNA) +* [stackit network-area region describe](./stackit_network-area_region_describe.md) - Describes a regional configuration for a STACKIT Network Area (SNA) +* [stackit network-area region list](./stackit_network-area_region_list.md) - Lists all configured regions for a STACKIT Network Area (SNA) +* [stackit network-area region update](./stackit_network-area_region_update.md) - Updates a existing regional configuration for a STACKIT Network Area (SNA) + diff --git a/docs/stackit_network-area_region_create.md b/docs/stackit_network-area_region_create.md new file mode 100644 index 000000000..55632632f --- /dev/null +++ b/docs/stackit_network-area_region_create.md @@ -0,0 +1,58 @@ +## stackit network-area region create + +Creates a new regional configuration for a STACKIT Network Area (SNA) + +### Synopsis + +Creates a new regional configuration for a STACKIT Network Area (SNA). + +``` +stackit network-area region create [flags] +``` + +### Examples + +``` + Create a new regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24" and ipv4 transfer network "192.168.1.0/24" + $ stackit network-area region create --network-area-id xxx --region eu02 --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 + + Create a new regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", using the set region config + $ stackit config set --region eu02 + $ stackit network-area region create --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 + + Create a new regional configuration for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24", ipv4 transfer network "192.168.1.0/24", default prefix length "24", max prefix length "25" and min prefix length "20" + $ stackit network-area region create --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 --region "eu02" --ipv4-default-prefix-length 24 --ipv4-max-prefix-length 25 --ipv4-min-prefix-length 20 + + Create a new regional configuration for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24", ipv4 transfer network "192.168.1.0/24", default prefix length "24", max prefix length "25" and min prefix length "20" + $ stackit network-area region create --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 --region "eu02" --ipv4-default-prefix-length 24 --ipv4-max-prefix-length 25 --ipv4-min-prefix-length 20 +``` + +### Options + +``` + -h, --help Help for "stackit network-area region create" + --ipv4-default-nameservers strings List of default DNS name server IPs + --ipv4-default-prefix-length int The default prefix length for networks in the network area + --ipv4-max-prefix-length int The maximum prefix length for networks in the network area + --ipv4-min-prefix-length int The minimum prefix length for networks in the network area + --ipv4-network-ranges strings Network range to create in CIDR notation (default []) + --ipv4-transfer-network string Transfer network in CIDR notation + --network-area-id string STACKIT Network Area (SNA) ID + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area region](./stackit_network-area_region.md) - Provides functionality for regional configuration of STACKIT Network Area (SNA) + diff --git a/docs/stackit_network-area_region_delete.md b/docs/stackit_network-area_region_delete.md new file mode 100644 index 000000000..6f2193e5e --- /dev/null +++ b/docs/stackit_network-area_region_delete.md @@ -0,0 +1,46 @@ +## stackit network-area region delete + +Deletes a regional configuration for a STACKIT Network Area (SNA) + +### Synopsis + +Deletes a regional configuration for a STACKIT Network Area (SNA). + +``` +stackit network-area region delete [flags] +``` + +### Examples + +``` + Delete a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy" + $ stackit network-area region delete --network-area-id xxx --region eu02 --organization-id yyy + + Delete a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", using the set region config + $ stackit config set --region eu02 + $ stackit network-area region delete --network-area-id xxx --organization-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit network-area region delete" + --network-area-id string STACKIT Network Area (SNA) ID + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area region](./stackit_network-area_region.md) - Provides functionality for regional configuration of STACKIT Network Area (SNA) + diff --git a/docs/stackit_network-area_region_describe.md b/docs/stackit_network-area_region_describe.md new file mode 100644 index 000000000..e97ee813a --- /dev/null +++ b/docs/stackit_network-area_region_describe.md @@ -0,0 +1,46 @@ +## stackit network-area region describe + +Describes a regional configuration for a STACKIT Network Area (SNA) + +### Synopsis + +Describes a regional configuration for a STACKIT Network Area (SNA). + +``` +stackit network-area region describe [flags] +``` + +### Examples + +``` + Describe a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy" + $ stackit network-area region describe --network-area-id xxx --region eu02 --organization-id yyy + + Describe a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", using the set region config + $ stackit config set --region eu02 + $ stackit network-area region describe --network-area-id xxx --organization-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit network-area region describe" + --network-area-id string STACKIT Network Area (SNA) ID + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area region](./stackit_network-area_region.md) - Provides functionality for regional configuration of STACKIT Network Area (SNA) + diff --git a/docs/stackit_network-area_region_list.md b/docs/stackit_network-area_region_list.md new file mode 100644 index 000000000..2b6eaf673 --- /dev/null +++ b/docs/stackit_network-area_region_list.md @@ -0,0 +1,42 @@ +## stackit network-area region list + +Lists all configured regions for a STACKIT Network Area (SNA) + +### Synopsis + +Lists all configured regions for a STACKIT Network Area (SNA). + +``` +stackit network-area region list [flags] +``` + +### Examples + +``` + List all configured region for a STACKIT Network Area with ID "xxx" in organization with ID "yyy" + $ stackit network-area region list --network-area-id xxx --organization-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit network-area region list" + --network-area-id string STACKIT Network Area (SNA) ID + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area region](./stackit_network-area_region.md) - Provides functionality for regional configuration of STACKIT Network Area (SNA) + diff --git a/docs/stackit_network-area_region_update.md b/docs/stackit_network-area_region_update.md new file mode 100644 index 000000000..400d85bc7 --- /dev/null +++ b/docs/stackit_network-area_region_update.md @@ -0,0 +1,56 @@ +## stackit network-area region update + +Updates a existing regional configuration for a STACKIT Network Area (SNA) + +### Synopsis + +Updates a existing regional configuration for a STACKIT Network Area (SNA). + +``` +stackit network-area region update [flags] +``` + +### Examples + +``` + Update a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy" with new ipv4-default-nameservers "8.8.8.8" + $ stackit network-area region update --network-area-id xxx --region eu02 --organization-id yyy --ipv4-default-nameservers 8.8.8.8 + + Update a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy" with new ipv4-default-nameservers "8.8.8.8", using the set region config + $ stackit config set --region eu02 + $ stackit network-area region update --network-area-id xxx --organization-id yyy --ipv4-default-nameservers 8.8.8.8 + + Update a new regional configuration for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24", ipv4 transfer network "192.168.1.0/24", default prefix length "24", max prefix length "25" and min prefix length "20" + $ stackit network-area region update --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 --region "eu02" --ipv4-default-prefix-length 24 --ipv4-max-prefix-length 25 --ipv4-min-prefix-length 20 + + Update a new regional configuration for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24", ipv4 transfer network "192.168.1.0/24", default prefix length "24", max prefix length "25" and min prefix length "20" + $ stackit network-area region update --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 --region "eu02" --ipv4-default-prefix-length 24 --ipv4-max-prefix-length 25 --ipv4-min-prefix-length 20 +``` + +### Options + +``` + -h, --help Help for "stackit network-area region update" + --ipv4-default-nameservers strings List of default DNS name server IPs + --ipv4-default-prefix-length int The default prefix length for networks in the network area + --ipv4-max-prefix-length int The maximum prefix length for networks in the network area + --ipv4-min-prefix-length int The minimum prefix length for networks in the network area + --network-area-id string STACKIT Network Area (SNA) ID + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area region](./stackit_network-area_region.md) - Provides functionality for regional configuration of STACKIT Network Area (SNA) + diff --git a/docs/stackit_network-area_route_create.md b/docs/stackit_network-area_route_create.md index 79d239fee..ff697f896 100644 --- a/docs/stackit_network-area_route_create.md +++ b/docs/stackit_network-area_route_create.md @@ -15,22 +15,25 @@ stackit network-area route create [flags] ### Examples ``` - Create a static route with prefix "1.1.1.0/24" and next hop "1.1.1.1" in a STACKIT Network Area with ID "xxx" in organization with ID "yyy" - $ stackit network-area route create --organization-id yyy --network-area-id xxx --prefix 1.1.1.0/24 --next-hop 1.1.1.1 + Create a static route with destination "1.1.1.0/24" and next hop "1.1.1.1" in a STACKIT Network Area with ID "xxx" in organization with ID "yyy" + $ stackit network-area route create --organization-id yyy --network-area-id xxx --destination 1.1.1.0/24 --next-hop 1.1.1.1 - Create a static route with labels "key:value" and "foo:bar" with prefix "1.1.1.0/24" and next hop "1.1.1.1" in a STACKIT Network Area with ID "xxx" in organization with ID "yyy" - $ stackit network-area route create --labels key=value,foo=bar --organization-id yyy --network-area-id xxx --prefix 1.1.1.0/24 --next-hop 1.1.1.1 + Create a static route with labels "key:value" and "foo:bar" with destination "1.1.1.0/24" and next hop "1.1.1.1" in a STACKIT Network Area with ID "xxx" in organization with ID "yyy" + $ stackit network-area route create --labels key=value,foo=bar --organization-id yyy --network-area-id xxx --destination 1.1.1.0/24 --next-hop 1.1.1.1 ``` ### Options ``` + --destination string Destination route. Must be a valid IPv4 or IPv6 CIDR -h, --help Help for "stackit network-area route create" --labels stringToString Labels are key-value string pairs which can be attached to a route. 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 []) --network-area-id string STACKIT Network Area ID - --next-hop string Next hop IP address. Must be a valid IPv4 + --next-hop-ipv4 string Next hop IPv4 address + --next-hop-ipv6 string Next hop IPv6 address + --nexthop-blackhole Sets next hop to black hole + --nexthop-internet Sets next hop to internet --organization-id string Organization ID - --prefix string Static route prefix ``` ### Options inherited from parent commands diff --git a/docs/stackit_network-area_routing-table.md b/docs/stackit_network-area_routing-table.md new file mode 100644 index 000000000..d1aefa50a --- /dev/null +++ b/docs/stackit_network-area_routing-table.md @@ -0,0 +1,42 @@ +## stackit network-area routing-table + +Manage routing-tables and its according routes + +### Synopsis + +Manage routing-tables and their associated routes. + +This API is currently available only to selected customers. +To request access, please contact your account manager or submit a support ticket. + +``` +stackit network-area routing-table [flags] +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area](./stackit_network-area.md) - Provides functionality for STACKIT Network Area (SNA) +* [stackit network-area routing-table create](./stackit_network-area_routing-table_create.md) - Creates a routing-table +* [stackit network-area routing-table delete](./stackit_network-area_routing-table_delete.md) - Deletes a routing-table +* [stackit network-area routing-table describe](./stackit_network-area_routing-table_describe.md) - Describes a routing-table +* [stackit network-area routing-table list](./stackit_network-area_routing-table_list.md) - Lists all routing-tables +* [stackit network-area routing-table route](./stackit_network-area_routing-table_route.md) - Manages routes of a routing-table +* [stackit network-area routing-table update](./stackit_network-area_routing-table_update.md) - Updates a routing-table + diff --git a/docs/stackit_network-area_routing-table_create.md b/docs/stackit_network-area_routing-table_create.md new file mode 100644 index 000000000..b926dcc7d --- /dev/null +++ b/docs/stackit_network-area_routing-table_create.md @@ -0,0 +1,56 @@ +## stackit network-area routing-table create + +Creates a routing-table + +### Synopsis + +Creates a routing-table. + +``` +stackit network-area routing-table create [flags] +``` + +### Examples + +``` + Create a routing-table with name "rt" + $ stackit network-area routing-table create --organization-id xxx --network-area-id yyy --name "rt" + + Create a routing-table with name "rt" and description "some description" + $ stackit network-area routing-table create --organization-id xxx --network-area-id yyy --name "rt" --description "some description" + + Create a routing-table with name "rt" with system routes disabled + $ stackit network-area routing-table create --organization-id xxx --network-area-id yyy --name "rt" --system-routes=false + + Create a routing-table with name "rt" with dynamic routes disabled + $ stackit network-area routing-table create --organization-id xxx --network-area-id yyy --name "rt" --dynamic-routes=false +``` + +### Options + +``` + --description string Description of the routing-table + --dynamic-routes If set to false, prevents dynamic routes from propagating to the routing table. (default true) + -h, --help Help for "stackit network-area routing-table create" + --labels stringToString Key=value labels (default []) + --name string Name of the routing-table + --network-area-id string Network-Area ID + --organization-id string Organization ID + --system-routes If set to false, disables routes for project-to-project communication. (default true) +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table](./stackit_network-area_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_network-area_routing-table_delete.md b/docs/stackit_network-area_routing-table_delete.md new file mode 100644 index 000000000..59a38395b --- /dev/null +++ b/docs/stackit_network-area_routing-table_delete.md @@ -0,0 +1,42 @@ +## stackit network-area routing-table delete + +Deletes a routing-table + +### Synopsis + +Deletes a routing-table + +``` +stackit network-area routing-table delete ROUTING_TABLE_ID [flags] +``` + +### Examples + +``` + Delete a routing-table with ID "xxx" + $ stackit network-area routing-table delete xxx --organization-id yyy --network-area-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table delete" + --network-area-id string Network-Area ID + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table](./stackit_network-area_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_network-area_routing-table_describe.md b/docs/stackit_network-area_routing-table_describe.md new file mode 100644 index 000000000..b608ce830 --- /dev/null +++ b/docs/stackit_network-area_routing-table_describe.md @@ -0,0 +1,42 @@ +## stackit network-area routing-table describe + +Describes a routing-table + +### Synopsis + +Describes a routing-table + +``` +stackit network-area routing-table describe ROUTING_TABLE_ID [flags] +``` + +### Examples + +``` + Describe a routing-table + $ stackit network-area routing-table describe xxx --organization-id xxx --network-area-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table describe" + --network-area-id string Network-Area ID + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table](./stackit_network-area_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_network-area_routing-table_list.md b/docs/stackit_network-area_routing-table_list.md new file mode 100644 index 000000000..5b7a277d4 --- /dev/null +++ b/docs/stackit_network-area_routing-table_list.md @@ -0,0 +1,50 @@ +## stackit network-area routing-table list + +Lists all routing-tables + +### Synopsis + +Lists all routing-tables + +``` +stackit network-area routing-table list [flags] +``` + +### Examples + +``` + List all routing-tables + $ stackit network-area routing-table list --organization-id xxx --network-area-id yyy + + List all routing-tables with labels + $ stackit network-area routing-table list --label-selector env=dev,env=rc --organization-id xxx --network-area-id yyy + + List all routing-tables with labels and set limit to 10 + $ stackit network-area routing-table list --label-selector env=dev,env=rc --limit 10 --organization-id xxx --network-area-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table list" + --label-selector string Filter by label + --limit int Maximum number of entries to list + --network-area-id string Network-Area ID + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table](./stackit_network-area_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_network-area_routing-table_route.md b/docs/stackit_network-area_routing-table_route.md new file mode 100644 index 000000000..f23144acf --- /dev/null +++ b/docs/stackit_network-area_routing-table_route.md @@ -0,0 +1,38 @@ +## stackit network-area routing-table route + +Manages routes of a routing-table + +### Synopsis + +Manages routes of a routing-table + +``` +stackit network-area routing-table route [flags] +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table route" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table](./stackit_network-area_routing-table.md) - Manage routing-tables and its according routes +* [stackit network-area routing-table route create](./stackit_network-area_routing-table_route_create.md) - Creates a route in a routing-table +* [stackit network-area routing-table route delete](./stackit_network-area_routing-table_route_delete.md) - Deletes a route within a routing-table +* [stackit network-area routing-table route describe](./stackit_network-area_routing-table_route_describe.md) - Describes a route within a routing-table +* [stackit network-area routing-table route list](./stackit_network-area_routing-table_route_list.md) - Lists all routes within a routing-table +* [stackit network-area routing-table route update](./stackit_network-area_routing-table_route_update.md) - Updates a route in a routing-table + diff --git a/docs/stackit_network-area_routing-table_route_create.md b/docs/stackit_network-area_routing-table_route_create.md new file mode 100644 index 000000000..eb3c3ad52 --- /dev/null +++ b/docs/stackit_network-area_routing-table_route_create.md @@ -0,0 +1,54 @@ +## stackit network-area routing-table route create + +Creates a route in a routing-table + +### Synopsis + +Creates a route in a routing-table. + +``` +stackit network-area routing-table route create [flags] +``` + +### Examples + +``` + Create a route with CIDRv4 destination and IPv4 nexthop + $ stackit network-area routing-table route create --routing-table-id xxx --organization-id yyy --network-area-id zzz --destination-type cidrv4 --destination-value --nexthop-type ipv4 --nexthop-value + + Create a route with CIDRv6 destination and IPv6 nexthop + $ stackit network-area routing-table route create --routing-table-id xxx --organization-id yyy --network-area-id zzz --destination-type cidrv6 --destination-value --nexthop-type ipv6 --nexthop-value + + Create a route with CIDRv6 destination and Nexthop Internet + $ stackit network-area routing-table route create --routing-table-id xxx --organization-id yyy --network-area-id zzz --destination-type cidrv6 --destination-value --nexthop-type internet +``` + +### Options + +``` + --destination-type string Destination type + --destination-value string Destination value + -h, --help Help for "stackit network-area routing-table route create" + --labels stringToString Key=value labels (default []) + --network-area-id string Network-Area ID + --nexthop-type string Next hop type + --nexthop-value string NextHop value + --organization-id string Organization ID + --routing-table-id string Routing-Table 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table route](./stackit_network-area_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_network-area_routing-table_route_delete.md b/docs/stackit_network-area_routing-table_route_delete.md new file mode 100644 index 000000000..5f15e61d0 --- /dev/null +++ b/docs/stackit_network-area_routing-table_route_delete.md @@ -0,0 +1,43 @@ +## stackit network-area routing-table route delete + +Deletes a route within a routing-table + +### Synopsis + +Deletes a route within a routing-table + +``` +stackit network-area routing-table route delete routing-table-id [flags] +``` + +### Examples + +``` + Deletes a route within a routing-table + $ stackit network-area routing-table route delete xxx --routing-table-id xxx --organization-id yyy --network-area-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table route delete" + --network-area-id string Network-Area ID + --organization-id string Organization ID + --routing-table-id string Routing-Table 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table route](./stackit_network-area_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_network-area_routing-table_route_describe.md b/docs/stackit_network-area_routing-table_route_describe.md new file mode 100644 index 000000000..9b50da817 --- /dev/null +++ b/docs/stackit_network-area_routing-table_route_describe.md @@ -0,0 +1,43 @@ +## stackit network-area routing-table route describe + +Describes a route within a routing-table + +### Synopsis + +Describes a route within a routing-table + +``` +stackit network-area routing-table route describe ROUTE_ID [flags] +``` + +### Examples + +``` + Describe a route within a routing-table + $ stackit network-area routing-table route describe xxx --routing-table-id xxx --organization-id yyy --network-area-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table route describe" + --network-area-id string Network-Area ID + --organization-id string Organization ID + --routing-table-id string Routing-Table 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table route](./stackit_network-area_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_network-area_routing-table_route_list.md b/docs/stackit_network-area_routing-table_route_list.md new file mode 100644 index 000000000..901b8d593 --- /dev/null +++ b/docs/stackit_network-area_routing-table_route_list.md @@ -0,0 +1,51 @@ +## stackit network-area routing-table route list + +Lists all routes within a routing-table + +### Synopsis + +Lists all routes within a routing-table + +``` +stackit network-area routing-table route list [flags] +``` + +### Examples + +``` + List all routes within a routing-table + $ stackit network-area routing-table route list --routing-table-id xxx --organization-id yyy --network-area-id zzz + + List all routes within a routing-table with labels + $ stackit network-area routing-table list --routing-table-id xxx --organization-id yyy --network-area-id zzz --label-selector env=dev,env=rc + + List all routes within a routing-tables with labels and limit to 10 + $ stackit network-area routing-table list --routing-table-id xxx --organization-id yyy --network-area-id zzz --label-selector env=dev,env=rc --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table route list" + --label-selector string Filter by label + --limit int Maximum number of entries to list + --network-area-id string Network-Area ID + --organization-id string Organization ID + --routing-table-id string Routing-Table 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table route](./stackit_network-area_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_network-area_routing-table_route_update.md b/docs/stackit_network-area_routing-table_route_update.md new file mode 100644 index 000000000..22bdb11ad --- /dev/null +++ b/docs/stackit_network-area_routing-table_route_update.md @@ -0,0 +1,44 @@ +## stackit network-area routing-table route update + +Updates a route in a routing-table + +### Synopsis + +Updates a route in a routing-table. + +``` +stackit network-area routing-table route update ROUTE_ID [flags] +``` + +### Examples + +``` + Updates the label(s) of a route with ID "xxx" in a routing-table ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit network-area routing-table route update xxx --labels key=value,foo=bar --routing-table-id xxx --organization-id yyy --network-area-id zzz +``` + +### Options + +``` + -h, --help Help for "stackit network-area routing-table route update" + --labels stringToString Labels are key-value string pairs which can be attached to a route. 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 []) + --network-area-id string Network-Area ID + --organization-id string Organization ID + --routing-table-id string Routing-Table 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table route](./stackit_network-area_routing-table_route.md) - Manages routes of a routing-table + diff --git a/docs/stackit_network-area_routing-table_update.md b/docs/stackit_network-area_routing-table_update.md new file mode 100644 index 000000000..917565e38 --- /dev/null +++ b/docs/stackit_network-area_routing-table_update.md @@ -0,0 +1,59 @@ +## stackit network-area routing-table update + +Updates a routing-table + +### Synopsis + +Updates a routing-table. + +``` +stackit network-area routing-table update ROUTING_TABLE_ID [flags] +``` + +### Examples + +``` + Updates the label(s) of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit network-area routing-table update xxx --labels key=value,foo=bar --organization-id yyy --network-area-id zzz + + Updates the name of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit network-area routing-table update xxx --name foo --organization-id yyy --network-area-id zzz + + Updates the description of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit network-area routing-table update xxx --description foo --organization-id yyy --network-area-id zzz + + Disables the dynamic routes of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit network-area routing-table update xxx --organization-id yyy --network-area-id zzz --dynamic-routes=false + + Disables the system routes of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz" + $ stackit network-area routing-table update xxx --organization-id yyy --network-area-id zzz --system-routes=false +``` + +### Options + +``` + --description string Description of the routing-table + --dynamic-routes If set to false, prevents dynamic routes from propagating to the routing table. + -h, --help Help for "stackit network-area routing-table update" + --labels stringToString Key=value labels (default []) + --name string Name of the routing-table + --network-area-id string Network-Area ID + --organization-id string Organization ID + --system-routes If set to false, disables routes for project-to-project communication. +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit network-area routing-table](./stackit_network-area_routing-table.md) - Manage routing-tables and its according routes + diff --git a/docs/stackit_network-area_update.md b/docs/stackit_network-area_update.md index 57b32a662..77665f0e8 100644 --- a/docs/stackit_network-area_update.md +++ b/docs/stackit_network-area_update.md @@ -20,14 +20,10 @@ stackit network-area update AREA_ID [flags] ### Options ``` - --default-prefix-length int The default prefix length for networks in the network area - --dns-name-servers strings List of DNS name server IPs - -h, --help Help for "stackit network-area update" - --labels stringToString Labels are key-value string pairs which can be attached to a network-area. E.g. '--labels key1=value1,key2=value2,...' (default []) - --max-prefix-length int The maximum prefix length for networks in the network area - --min-prefix-length int The minimum prefix length for networks in the network area - -n, --name string Network area name - --organization-id string Organization ID + -h, --help Help for "stackit network-area update" + --labels stringToString Labels are key-value string pairs which can be attached to a network-area. E.g. '--labels key1=value1,key2=value2,...' (default []) + -n, --name string Network area name + --organization-id string Organization ID ``` ### Options inherited from parent commands diff --git a/docs/stackit_network-interface_list.md b/docs/stackit_network-interface_list.md index f202a6667..50276fe12 100644 --- a/docs/stackit_network-interface_list.md +++ b/docs/stackit_network-interface_list.md @@ -13,6 +13,9 @@ stackit network-interface list [flags] ### Examples ``` + Lists all network interfaces + $ stackit network-interface list + Lists all network interfaces with network ID "xxx" $ stackit network-interface list --network-id xxx diff --git a/docs/stackit_network_create.md b/docs/stackit_network_create.md index 21d9e863c..44934bee3 100644 --- a/docs/stackit_network_create.md +++ b/docs/stackit_network_create.md @@ -26,10 +26,13 @@ stackit network create [flags] $ stackit network create --name network-1 --labels key=value,key1=value1 Create an IPv4 network with name "network-1" with DNS name servers, a prefix and a gateway - $ stackit network create --name network-1 --ipv4-dns-name-servers "1.1.1.1,8.8.8.8,9.9.9.9" --ipv4-prefix "10.1.2.0/24" --ipv4-gateway "10.1.2.3" + $ stackit network create --name network-1 --non-routed --ipv4-dns-name-servers "1.1.1.1,8.8.8.8,9.9.9.9" --ipv4-prefix "10.1.2.0/24" --ipv4-gateway "10.1.2.3" Create an IPv6 network with name "network-1" with DNS name servers, a prefix and a gateway $ stackit network create --name network-1 --ipv6-dns-name-servers "2001:4860:4860::8888,2001:4860:4860::8844" --ipv6-prefix "2001:4860:4860::8888" --ipv6-gateway "2001:4860:4860::8888" + + Create a network with name "network-1" and attach routing-table "xxx" + $ stackit network create --name network-1 --routing-table-id xxx ``` ### Options @@ -49,6 +52,7 @@ stackit network create [flags] --no-ipv4-gateway If set to true, the network doesn't have an IPv4 gateway --no-ipv6-gateway If set to true, the network doesn't have an IPv6 gateway --non-routed If set to true, the network is not routed and therefore not accessible from other networks + --routing-table-id string The ID of the routing-table for the network ``` ### Options inherited from parent commands diff --git a/docs/stackit_network_update.md b/docs/stackit_network_update.md index 313ce68fa..7069b26d2 100644 --- a/docs/stackit_network_update.md +++ b/docs/stackit_network_update.md @@ -24,6 +24,9 @@ stackit network update NETWORK_ID [flags] Update IPv6 network with ID "xxx" with new name "network-1-new", new gateway and new DNS name servers $ stackit network update xxx --name network-1-new --ipv6-dns-name-servers "2001:4860:4860::8888" --ipv6-gateway "2001:4860:4860::8888" + + Update network with ID "xxx" with new routing-table id "xxx" + $ stackit network update xxx --routing-table-id xxx ``` ### Options @@ -38,6 +41,7 @@ stackit network update NETWORK_ID [flags] -n, --name string Network name --no-ipv4-gateway If set to true, the network doesn't have an IPv4 gateway --no-ipv6-gateway If set to true, the network doesn't have an IPv6 gateway + --routing-table-id string The ID of the routing-table for the network ``` ### Options inherited from parent commands diff --git a/docs/stackit_object-storage.md b/docs/stackit_object-storage.md index 5caa02380..bae7c2496 100644 --- a/docs/stackit_object-storage.md +++ b/docs/stackit_object-storage.md @@ -31,6 +31,7 @@ stackit object-storage [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit object-storage bucket](./stackit_object-storage_bucket.md) - Provides functionality for Object Storage buckets +* [stackit object-storage compliance-lock](./stackit_object-storage_compliance-lock.md) - Provides functionality to manage Object Storage compliance lock * [stackit object-storage credentials](./stackit_object-storage_credentials.md) - Provides functionality for Object Storage credentials * [stackit object-storage credentials-group](./stackit_object-storage_credentials-group.md) - Provides functionality for Object Storage credentials group * [stackit object-storage disable](./stackit_object-storage_disable.md) - Disables Object Storage for a project diff --git a/docs/stackit_object-storage_bucket_create.md b/docs/stackit_object-storage_bucket_create.md index 1c07eb540..58f14a21a 100644 --- a/docs/stackit_object-storage_bucket_create.md +++ b/docs/stackit_object-storage_bucket_create.md @@ -15,12 +15,16 @@ stackit object-storage bucket create BUCKET_NAME [flags] ``` Create an Object Storage bucket with name "my-bucket" $ stackit object-storage bucket create my-bucket + + Create an Object Storage bucket with enabled object-lock + $ stackit object-storage bucket create my-bucket --object-lock-enabled ``` ### Options ``` - -h, --help Help for "stackit object-storage bucket create" + -h, --help Help for "stackit object-storage bucket create" + --object-lock-enabled is the object-lock enabled for the bucket ``` ### Options inherited from parent commands diff --git a/docs/stackit_object-storage_compliance-lock.md b/docs/stackit_object-storage_compliance-lock.md new file mode 100644 index 000000000..435807d29 --- /dev/null +++ b/docs/stackit_object-storage_compliance-lock.md @@ -0,0 +1,36 @@ +## stackit object-storage compliance-lock + +Provides functionality to manage Object Storage compliance lock + +### Synopsis + +Provides functionality to manage Object Storage compliance lock. + +``` +stackit object-storage compliance-lock [flags] +``` + +### Options + +``` + -h, --help Help for "stackit object-storage compliance-lock" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit object-storage](./stackit_object-storage.md) - Provides functionality for Object Storage +* [stackit object-storage compliance-lock describe](./stackit_object-storage_compliance-lock_describe.md) - Describe object storage compliance lock +* [stackit object-storage compliance-lock lock](./stackit_object-storage_compliance-lock_lock.md) - Create object storage compliance lock +* [stackit object-storage compliance-lock unlock](./stackit_object-storage_compliance-lock_unlock.md) - Delete object storage compliance lock + diff --git a/docs/stackit_object-storage_compliance-lock_describe.md b/docs/stackit_object-storage_compliance-lock_describe.md new file mode 100644 index 000000000..393b034da --- /dev/null +++ b/docs/stackit_object-storage_compliance-lock_describe.md @@ -0,0 +1,40 @@ +## stackit object-storage compliance-lock describe + +Describe object storage compliance lock + +### Synopsis + +Describe object storage compliance lock. + +``` +stackit object-storage compliance-lock describe [flags] +``` + +### Examples + +``` + Describe object storage compliance lock + $ stackit object-storage compliance-lock describe +``` + +### Options + +``` + -h, --help Help for "stackit object-storage compliance-lock 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit object-storage compliance-lock](./stackit_object-storage_compliance-lock.md) - Provides functionality to manage Object Storage compliance lock + diff --git a/docs/stackit_object-storage_compliance-lock_lock.md b/docs/stackit_object-storage_compliance-lock_lock.md new file mode 100644 index 000000000..98a55265d --- /dev/null +++ b/docs/stackit_object-storage_compliance-lock_lock.md @@ -0,0 +1,40 @@ +## stackit object-storage compliance-lock lock + +Create object storage compliance lock + +### Synopsis + +Create object storage compliance lock. + +``` +stackit object-storage compliance-lock lock [flags] +``` + +### Examples + +``` + Create object storage compliance lock + $ stackit object-storage compliance-lock lock +``` + +### Options + +``` + -h, --help Help for "stackit object-storage compliance-lock lock" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit object-storage compliance-lock](./stackit_object-storage_compliance-lock.md) - Provides functionality to manage Object Storage compliance lock + diff --git a/docs/stackit_object-storage_compliance-lock_unlock.md b/docs/stackit_object-storage_compliance-lock_unlock.md new file mode 100644 index 000000000..5666c3a40 --- /dev/null +++ b/docs/stackit_object-storage_compliance-lock_unlock.md @@ -0,0 +1,40 @@ +## stackit object-storage compliance-lock unlock + +Delete object storage compliance lock + +### Synopsis + +Delete object storage compliance lock. + +``` +stackit object-storage compliance-lock unlock [flags] +``` + +### Examples + +``` + Delete object storage compliance lock + $ stackit object-storage compliance-lock unlock +``` + +### Options + +``` + -h, --help Help for "stackit object-storage compliance-lock unlock" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit object-storage compliance-lock](./stackit_object-storage_compliance-lock.md) - Provides functionality to manage Object Storage compliance lock + diff --git a/docs/stackit_observability_grafana_describe.md b/docs/stackit_observability_grafana_describe.md index 4eea4982a..e44800614 100644 --- a/docs/stackit_observability_grafana_describe.md +++ b/docs/stackit_observability_grafana_describe.md @@ -6,7 +6,6 @@ Shows details of the Grafana configuration of an Observability instance Shows details of the Grafana configuration of an Observability instance. The Grafana dashboard URL and initial credentials (admin user and password) will be shown in the "pretty" output format. These credentials are only valid for first login. Please change the password after first login. After changing, the initial password is no longer valid. -The initial password is hidden by default, if you want to show it use the "--show-password" flag. ``` stackit observability grafana describe INSTANCE_ID [flags] @@ -18,9 +17,6 @@ stackit observability grafana describe INSTANCE_ID [flags] Get details of the Grafana configuration of an Observability instance with ID "xxx" $ stackit observability grafana describe xxx - Get details of the Grafana configuration of an Observability instance with ID "xxx" and show the initial admin password - $ stackit observability grafana describe xxx --show-password - Get details of the Grafana configuration of an Observability instance with ID "xxx" in JSON format $ stackit observability grafana describe xxx --output-format json ``` @@ -28,8 +24,7 @@ stackit observability grafana describe INSTANCE_ID [flags] ### Options ``` - -h, --help Help for "stackit observability grafana describe" - -s, --show-password Show password in output + -h, --help Help for "stackit observability grafana describe" ``` ### Options inherited from parent commands diff --git a/docs/stackit_organization.md b/docs/stackit_organization.md index f1bbaedde..ad124b975 100644 --- a/docs/stackit_organization.md +++ b/docs/stackit_organization.md @@ -31,6 +31,8 @@ stackit organization [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit organization describe](./stackit_organization_describe.md) - Show an organization +* [stackit organization list](./stackit_organization_list.md) - Lists all organizations * [stackit organization member](./stackit_organization_member.md) - Manages organization members * [stackit organization role](./stackit_organization_role.md) - Manages organization roles diff --git a/docs/stackit_organization_describe.md b/docs/stackit_organization_describe.md new file mode 100644 index 000000000..795cf38e5 --- /dev/null +++ b/docs/stackit_organization_describe.md @@ -0,0 +1,43 @@ +## stackit organization describe + +Show an organization + +### Synopsis + +Show an organization. + +``` +stackit organization describe [flags] +``` + +### Examples + +``` + Describe the organization with the organization uuid "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + $ stackit organization describe xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + + Describe the organization with the container id "foo-bar-organization" + $ stackit organization describe foo-bar-organization +``` + +### Options + +``` + -h, --help Help for "stackit organization 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit organization](./stackit_organization.md) - Manages organizations + diff --git a/docs/stackit_organization_list.md b/docs/stackit_organization_list.md new file mode 100644 index 000000000..a258807ba --- /dev/null +++ b/docs/stackit_organization_list.md @@ -0,0 +1,44 @@ +## stackit organization list + +Lists all organizations + +### Synopsis + +Lists all organizations. + +``` +stackit organization list [flags] +``` + +### Examples + +``` + Lists organizations for your user + $ stackit organization list + + Lists the first 10 organizations + $ stackit organization list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit organization list" + --limit int Maximum number of entries to list (default 50) +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit organization](./stackit_organization.md) - Manages organizations + diff --git a/docs/stackit_public-ip.md b/docs/stackit_public-ip.md index 99bfd899f..d5dcafd53 100644 --- a/docs/stackit_public-ip.md +++ b/docs/stackit_public-ip.md @@ -36,5 +36,6 @@ stackit public-ip [flags] * [stackit public-ip describe](./stackit_public-ip_describe.md) - Shows details of a Public IP * [stackit public-ip disassociate](./stackit_public-ip_disassociate.md) - Disassociates a Public IP from a network interface or a virtual IP * [stackit public-ip list](./stackit_public-ip_list.md) - Lists all Public IPs of a project +* [stackit public-ip ranges](./stackit_public-ip_ranges.md) - Provides functionality for STACKIT public-ip ranges * [stackit public-ip update](./stackit_public-ip_update.md) - Updates a Public IP diff --git a/docs/stackit_public-ip_ranges.md b/docs/stackit_public-ip_ranges.md new file mode 100644 index 000000000..025ddba9b --- /dev/null +++ b/docs/stackit_public-ip_ranges.md @@ -0,0 +1,34 @@ +## stackit public-ip ranges + +Provides functionality for STACKIT public-ip ranges + +### Synopsis + +Provides functionality for STACKIT public-ip ranges + +``` +stackit public-ip ranges [flags] +``` + +### Options + +``` + -h, --help Help for "stackit public-ip ranges" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit public-ip](./stackit_public-ip.md) - Provides functionality for public IPs +* [stackit public-ip ranges list](./stackit_public-ip_ranges_list.md) - Lists all STACKIT public-ip ranges + diff --git a/docs/stackit_public-ip_ranges_list.md b/docs/stackit_public-ip_ranges_list.md new file mode 100644 index 000000000..c152b9851 --- /dev/null +++ b/docs/stackit_public-ip_ranges_list.md @@ -0,0 +1,47 @@ +## stackit public-ip ranges list + +Lists all STACKIT public-ip ranges + +### Synopsis + +Lists all STACKIT public-ip ranges. + +``` +stackit public-ip ranges list [flags] +``` + +### Examples + +``` + Lists all STACKIT public-ip ranges + $ stackit public-ip ranges list + + Lists all STACKIT public-ip ranges, piping to a tool like fzf for interactive selection + $ stackit public-ip ranges list -o pretty | fzf + + Lists up to 10 STACKIT public-ip ranges + $ stackit public-ip ranges list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit public-ip ranges 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit public-ip ranges](./stackit_public-ip_ranges.md) - Provides functionality for STACKIT public-ip ranges + diff --git a/docs/stackit_secrets-manager_instance_create.md b/docs/stackit_secrets-manager_instance_create.md index 379de7785..65108008a 100644 --- a/docs/stackit_secrets-manager_instance_create.md +++ b/docs/stackit_secrets-manager_instance_create.md @@ -18,14 +18,21 @@ stackit secrets-manager instance create [flags] Create a Secrets Manager instance with name "my-instance" and specify IP range which is allowed to access it $ stackit secrets-manager instance create --name my-instance --acl 1.2.3.0/24 + + Create a Secrets Manager instance with name "my-instance" and configure KMS key options + $ stackit secrets-manager instance create --name my-instance --kms-key-id key-id --kms-keyring-id keyring-id --kms-key-version 1 --kms-service-account-email my-service-account-1234567@sa.stackit.cloud ``` ### Options ``` - --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) - -h, --help Help for "stackit secrets-manager instance create" - -n, --name string Instance name + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + -h, --help Help for "stackit secrets-manager instance create" + --kms-key-id string ID of the KMS key to use for encryption + --kms-key-version int Version of the KMS key + --kms-keyring-id string ID of the KMS key ring + --kms-service-account-email string Service account email for KMS access + -n, --name string Instance name ``` ### Options inherited from parent commands diff --git a/docs/stackit_secrets-manager_instance_update.md b/docs/stackit_secrets-manager_instance_update.md index cf40d3c1a..c000c7cad 100644 --- a/docs/stackit_secrets-manager_instance_update.md +++ b/docs/stackit_secrets-manager_instance_update.md @@ -13,15 +13,29 @@ stackit secrets-manager instance update INSTANCE_ID [flags] ### Examples ``` + Update the name of a Secrets Manager instance with ID "xxx" + $ stackit secrets-manager instance update xxx --name my-new-name + Update the range of IPs allowed to access a Secrets Manager instance with ID "xxx" $ stackit secrets-manager instance update xxx --acl 1.2.3.0/24 + + Update the name and ACLs of a Secrets Manager instance with ID "xxx" + $ stackit secrets-manager instance update xxx --name my-new-name --acl 1.2.3.0/24 + + Update the KMS key settings of a Secrets Manager instance with ID "xxx" + $ stackit secrets-manager instance update xxx --name my-instance --kms-key-id key-id --kms-keyring-id keyring-id --kms-key-version 1 --kms-service-account-email my-service-account-1234567@sa.stackit.cloud ``` ### Options ``` - --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) - -h, --help Help for "stackit secrets-manager instance update" + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + -h, --help Help for "stackit secrets-manager instance update" + --kms-key-id string ID of the KMS key to use for encryption + --kms-key-version int Version of the KMS key + --kms-keyring-id string ID of the KMS key ring + --kms-service-account-email string Service account email for KMS access + -n, --name string Instance name ``` ### Options inherited from parent commands diff --git a/docs/stackit_server.md b/docs/stackit_server.md index 83bf55541..267a8df3b 100644 --- a/docs/stackit_server.md +++ b/docs/stackit_server.md @@ -46,6 +46,7 @@ stackit server [flags] * [stackit server reboot](./stackit_server_reboot.md) - Reboots a server * [stackit server rescue](./stackit_server_rescue.md) - Rescues an existing server * [stackit server resize](./stackit_server_resize.md) - Resizes the server to the given machine type +* [stackit server security-group](./stackit_server_security-group.md) - Allows attaching/detaching security groups to servers * [stackit server service-account](./stackit_server_service-account.md) - Allows attaching/detaching service accounts to servers * [stackit server start](./stackit_server_start.md) - Starts an existing server or allocates the server if deallocated * [stackit server stop](./stackit_server_stop.md) - Stops an existing server diff --git a/docs/stackit_server_create.md b/docs/stackit_server_create.md index 23378c5fa..c1c4e9b21 100644 --- a/docs/stackit_server_create.md +++ b/docs/stackit_server_create.md @@ -38,7 +38,7 @@ stackit server create [flags] $ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --volumes yyy Create a server with user data (cloud-init) - $ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data @path/to/file.yaml") + $ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data @path/to/file.yaml ``` ### Options @@ -55,7 +55,7 @@ stackit server create [flags] --image-id string The image ID to be used for an ephemeral disk on the server. Either 'image-id' or 'boot-volume-...' flags are required --keypair-name string The name of the SSH keypair used during the server creation --labels stringToString Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...' (default []) - --machine-type string Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html + --machine-type string Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/products/compute-engine/server/basics/machine-types/ -n, --name string Server name --network-id string ID of the network for the initial networking setup for the server creation --network-interface-ids strings List of network interface IDs for the initial networking setup for the server creation diff --git a/docs/stackit_server_machine-type_list.md b/docs/stackit_server_machine-type_list.md index fd2ed7afe..127563908 100644 --- a/docs/stackit_server_machine-type_list.md +++ b/docs/stackit_server_machine-type_list.md @@ -21,13 +21,20 @@ stackit server machine-type list [flags] List the first 10 machine types $ stackit server machine-type list --limit=10 + + List machine types with exactly 2 vCPUs + $ stackit server machine-type list --filter="vcpus==2" + + List machine types with at least 2 vCPUs and 2048 MB RAM + $ stackit server machine-type list --filter="vcpus >= 2 && ram >= 2048" ``` ### Options ``` - -h, --help Help for "stackit server machine-type list" - --limit int Limit the output to the first n elements + --filter string Filter resources by fields. A subset of expr-lang is supported. See https://expr-lang.org/docs/language-definition for usage details + -h, --help Help for "stackit server machine-type list" + --limit int Limit the output to the first n elements ``` ### Options inherited from parent commands diff --git a/docs/stackit_server_resize.md b/docs/stackit_server_resize.md index c91335432..bbcb239f5 100644 --- a/docs/stackit_server_resize.md +++ b/docs/stackit_server_resize.md @@ -21,7 +21,7 @@ stackit server resize SERVER_ID [flags] ``` -h, --help Help for "stackit server resize" - --machine-type string Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html + --machine-type string Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/products/compute-engine/server/basics/machine-types/ ``` ### Options inherited from parent commands diff --git a/docs/stackit_server_security-group.md b/docs/stackit_server_security-group.md new file mode 100644 index 000000000..b44ce57e4 --- /dev/null +++ b/docs/stackit_server_security-group.md @@ -0,0 +1,35 @@ +## stackit server security-group + +Allows attaching/detaching security groups to servers + +### Synopsis + +Allows attaching/detaching security groups to servers. + +``` +stackit server security-group [flags] +``` + +### Options + +``` + -h, --help Help for "stackit server security-group" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit server](./stackit_server.md) - Provides functionality for servers +* [stackit server security-group attach](./stackit_server_security-group_attach.md) - Attaches a security group to a server +* [stackit server security-group detach](./stackit_server_security-group_detach.md) - Detaches a security group from a server + diff --git a/docs/stackit_server_security-group_attach.md b/docs/stackit_server_security-group_attach.md new file mode 100644 index 000000000..c42466381 --- /dev/null +++ b/docs/stackit_server_security-group_attach.md @@ -0,0 +1,42 @@ +## stackit server security-group attach + +Attaches a security group to a server + +### Synopsis + +Attaches a security group to a server. + +``` +stackit server security-group attach [flags] +``` + +### Examples + +``` + Attach a security group with ID "xxx" to a server with ID "yyy" + $ stackit server security-group attach --server-id yyy --security-group-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit server security-group attach" + --security-group-id string Security Group ID + --server-id string Server 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit server security-group](./stackit_server_security-group.md) - Allows attaching/detaching security groups to servers + diff --git a/docs/stackit_server_security-group_detach.md b/docs/stackit_server_security-group_detach.md new file mode 100644 index 000000000..493eee69f --- /dev/null +++ b/docs/stackit_server_security-group_detach.md @@ -0,0 +1,42 @@ +## stackit server security-group detach + +Detaches a security group from a server + +### Synopsis + +Detaches a security group from a server. + +``` +stackit server security-group detach [flags] +``` + +### Examples + +``` + Detach a security group with ID "xxx" from a server with ID "yyy" + $ stackit server security-group detach --server-id yyy --security-group-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit server security-group detach" + --security-group-id string Security Group ID + --server-id string Server 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit server security-group](./stackit_server_security-group.md) - Allows attaching/detaching security groups to servers + diff --git a/docs/stackit_ske_cluster.md b/docs/stackit_ske_cluster.md index 7df9ba39e..a575e5495 100644 --- a/docs/stackit_ske_cluster.md +++ b/docs/stackit_ske_cluster.md @@ -30,10 +30,14 @@ stackit ske cluster [flags] ### 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 create](./stackit_ske_cluster_create.md) - Creates a SKE cluster * [stackit ske cluster delete](./stackit_ske_cluster_delete.md) - Deletes a SKE cluster -* [stackit ske cluster describe](./stackit_ske_cluster_describe.md) - Shows details of a SKE cluster +* [stackit ske cluster describe](./stackit_ske_cluster_describe.md) - Shows 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 hibernate](./stackit_ske_cluster_hibernate.md) - Trigger hibernate for a SKE cluster * [stackit ske cluster list](./stackit_ske_cluster_list.md) - Lists all SKE clusters -* [stackit ske cluster update](./stackit_ske_cluster_update.md) - Updates an SKE cluster +* [stackit ske cluster maintenance](./stackit_ske_cluster_maintenance.md) - Trigger maintenance for a SKE cluster +* [stackit ske cluster reconcile](./stackit_ske_cluster_reconcile.md) - Trigger reconcile for a SKE cluster +* [stackit ske cluster update](./stackit_ske_cluster_update.md) - Updates a SKE cluster +* [stackit ske cluster wakeup](./stackit_ske_cluster_wakeup.md) - Trigger wakeup from hibernation for a SKE cluster diff --git a/docs/stackit_ske_cluster_create.md b/docs/stackit_ske_cluster_create.md index fa4e0b492..3c94a7bdd 100644 --- a/docs/stackit_ske_cluster_create.md +++ b/docs/stackit_ske_cluster_create.md @@ -1,6 +1,6 @@ ## stackit ske cluster create -Creates an SKE cluster +Creates a SKE cluster ### Synopsis @@ -15,13 +15,13 @@ stackit ske cluster create CLUSTER_NAME [flags] ### Examples ``` - Create an SKE cluster using default configuration + Create a 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" + Create a 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 + Create a 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 diff --git a/docs/stackit_ske_cluster_delete.md b/docs/stackit_ske_cluster_delete.md index ad2915d87..c1c0407a7 100644 --- a/docs/stackit_ske_cluster_delete.md +++ b/docs/stackit_ske_cluster_delete.md @@ -13,7 +13,7 @@ stackit ske cluster delete CLUSTER_NAME [flags] ### Examples ``` - Delete an SKE cluster with name "my-cluster" + Delete a SKE cluster with name "my-cluster" $ stackit ske cluster delete my-cluster ``` diff --git a/docs/stackit_ske_cluster_describe.md b/docs/stackit_ske_cluster_describe.md index eb30860a9..91b3949fc 100644 --- a/docs/stackit_ske_cluster_describe.md +++ b/docs/stackit_ske_cluster_describe.md @@ -1,10 +1,10 @@ ## stackit ske cluster describe -Shows details of a SKE cluster +Shows details of a SKE cluster ### Synopsis -Shows details of a STACKIT Kubernetes Engine (SKE) cluster. +Shows details of a STACKIT Kubernetes Engine (SKE) cluster. ``` stackit ske cluster describe CLUSTER_NAME [flags] @@ -13,10 +13,10 @@ stackit ske cluster describe CLUSTER_NAME [flags] ### Examples ``` - Get details of an SKE cluster with name "my-cluster" + Get details of a SKE cluster with name "my-cluster" $ stackit ske cluster describe my-cluster - Get details of an SKE cluster with name "my-cluster" in JSON format + Get details of a SKE cluster with name "my-cluster" in JSON format $ stackit ske cluster describe my-cluster --output-format json ``` diff --git a/docs/stackit_ske_cluster_hibernate.md b/docs/stackit_ske_cluster_hibernate.md new file mode 100644 index 000000000..20baddd1b --- /dev/null +++ b/docs/stackit_ske_cluster_hibernate.md @@ -0,0 +1,40 @@ +## stackit ske cluster hibernate + +Trigger hibernate for a SKE cluster + +### Synopsis + +Trigger hibernate for a STACKIT Kubernetes Engine (SKE) cluster. + +``` +stackit ske cluster hibernate CLUSTER_NAME [flags] +``` + +### Examples + +``` + Trigger hibernate for a SKE cluster with name "my-cluster" + $ stackit ske cluster hibernate my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster hibernate" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_maintenance.md b/docs/stackit_ske_cluster_maintenance.md new file mode 100644 index 000000000..0a6c6540c --- /dev/null +++ b/docs/stackit_ske_cluster_maintenance.md @@ -0,0 +1,40 @@ +## stackit ske cluster maintenance + +Trigger maintenance for a SKE cluster + +### Synopsis + +Trigger maintenance for a STACKIT Kubernetes Engine (SKE) cluster. + +``` +stackit ske cluster maintenance CLUSTER_NAME [flags] +``` + +### Examples + +``` + Trigger maintenance for a SKE cluster with name "my-cluster" + $ stackit ske cluster maintenance my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster maintenance" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_reconcile.md b/docs/stackit_ske_cluster_reconcile.md new file mode 100644 index 000000000..64887316d --- /dev/null +++ b/docs/stackit_ske_cluster_reconcile.md @@ -0,0 +1,40 @@ +## stackit ske cluster reconcile + +Trigger reconcile for a SKE cluster + +### Synopsis + +Trigger reconcile for a STACKIT Kubernetes Engine (SKE) cluster. + +``` +stackit ske cluster reconcile CLUSTER_NAME [flags] +``` + +### Examples + +``` + Trigger reconcile for a SKE cluster with name "my-cluster" + $ stackit ske cluster reconcile my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster reconcile" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### 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 index 5209cc5df..24fa95748 100644 --- a/docs/stackit_ske_cluster_update.md +++ b/docs/stackit_ske_cluster_update.md @@ -1,6 +1,6 @@ ## stackit ske cluster update -Updates an SKE cluster +Updates a SKE cluster ### Synopsis @@ -15,10 +15,10 @@ stackit ske cluster update CLUSTER_NAME [flags] ### Examples ``` - Update an SKE cluster using an API payload sourced from the file "./payload.json" + Update a 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 + Update a 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 diff --git a/docs/stackit_ske_cluster_wakeup.md b/docs/stackit_ske_cluster_wakeup.md new file mode 100644 index 000000000..7b07e9965 --- /dev/null +++ b/docs/stackit_ske_cluster_wakeup.md @@ -0,0 +1,40 @@ +## stackit ske cluster wakeup + +Trigger wakeup from hibernation for a SKE cluster + +### Synopsis + +Trigger wakeup from hibernation for a STACKIT Kubernetes Engine (SKE) cluster. + +``` +stackit ske cluster wakeup CLUSTER_NAME [flags] +``` + +### Examples + +``` + Trigger wakeup from hibernation for a SKE cluster with name "my-cluster" + $ stackit ske cluster wakeup my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster wakeup" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_credentials_complete-rotation.md b/docs/stackit_ske_credentials_complete-rotation.md index 12536dba5..7df00136f 100644 --- a/docs/stackit_ske_credentials_complete-rotation.md +++ b/docs/stackit_ske_credentials_complete-rotation.md @@ -14,7 +14,7 @@ To ensure continued access to the Kubernetes cluster, please update your kubecon If you haven't, please start the process by running: $ stackit ske credentials start-rotation my-cluster -For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html +For more information, visit: https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/rotate-ske-credentials/ ``` stackit ske credentials complete-rotation CLUSTER_NAME [flags] diff --git a/docs/stackit_ske_credentials_start-rotation.md b/docs/stackit_ske_credentials_start-rotation.md index aa8160adf..05200a386 100644 --- a/docs/stackit_ske_credentials_start-rotation.md +++ b/docs/stackit_ske_credentials_start-rotation.md @@ -18,7 +18,7 @@ After completing the rotation of credentials, you can generate a new kubeconfig $ stackit ske kubeconfig create my-cluster Complete the rotation by running: $ stackit ske credentials complete-rotation my-cluster -For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html +For more information, visit: https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/rotate-ske-credentials/ ``` stackit ske credentials start-rotation CLUSTER_NAME [flags] diff --git a/docs/stackit_ske_kubeconfig.md b/docs/stackit_ske_kubeconfig.md index 5c7d3adf0..83634e149 100644 --- a/docs/stackit_ske_kubeconfig.md +++ b/docs/stackit_ske_kubeconfig.md @@ -30,6 +30,6 @@ stackit ske kubeconfig [flags] ### SEE ALSO * [stackit ske](./stackit_ske.md) - Provides functionality for SKE -* [stackit ske kubeconfig create](./stackit_ske_kubeconfig_create.md) - Creates or update a kubeconfig for an SKE cluster +* [stackit ske kubeconfig create](./stackit_ske_kubeconfig_create.md) - Creates or update a kubeconfig for a SKE cluster * [stackit ske kubeconfig login](./stackit_ske_kubeconfig_login.md) - Login plugin for kubernetes clients diff --git a/docs/stackit_ske_kubeconfig_create.md b/docs/stackit_ske_kubeconfig_create.md index d3d0e5622..5717a31f8 100644 --- a/docs/stackit_ske_kubeconfig_create.md +++ b/docs/stackit_ske_kubeconfig_create.md @@ -1,13 +1,13 @@ ## stackit ske kubeconfig create -Creates or update a kubeconfig for an SKE cluster +Creates or update a kubeconfig for a SKE cluster ### Synopsis -Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster, if the config exits in the kubeconfig file the information will be updated. +Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster. By default an admin kubeconfig is created. Use the `--idp` option to create an IDP kubeconfig that authenticates via the STACKIT IDP. -By default, the kubeconfig information of the SKE cluster is merged into the default kubeconfig file of the current user. If the kubeconfig file doesn't exist, a new one will be created. -You can override this behavior by specifying a custom filepath with the --filepath flag. +If the config exists in the kubeconfig file the information will be updated. By default, the kubeconfig information of the SKE cluster is merged into the default kubeconfig file of the current user. If the kubeconfig file doesn't exist, a new one will be created. +You can override this behavior by specifying a custom filepath using the --filepath flag or by setting the KUBECONFIG env variable (fallback). An expiration time can be set for the kubeconfig. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is 1h. @@ -20,25 +20,28 @@ stackit ske kubeconfig create CLUSTER_NAME [flags] ### Examples ``` - Create or update a kubeconfig for the SKE cluster with name "my-cluster. If the config exits in the kubeconfig file the information will be updated." - $ stackit ske kubeconfig create my-cluster - - Get a login kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command. + Get a short-lived admin kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid admin credentials via the `stackit ske kubeconfig login` command. $ stackit ske kubeconfig create my-cluster --login - Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 30 days. If the config exits in the kubeconfig file the information will be updated. + Get an IDP kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not grant permissions in the cluster by default and obtains credentials on-demand via the `stackit ske kubeconfig login` command. + $ stackit ske kubeconfig create my-cluster --idp + + Create or update a short-lived admin kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath. If the config exits in the kubeconfig file the information will be updated. + $ stackit ske kubeconfig create my-cluster --login --filepath /path/to/config + + Create or update an admin kubeconfig for the SKE cluster with name "my-cluster". If the config exits in the kubeconfig file the information will be updated." + $ stackit ske kubeconfig create my-cluster + + Create an admin kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 30 days. If the config exits in the kubeconfig file the information will be updated. $ stackit ske kubeconfig create my-cluster --expiration 30d - Create or update a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 2 months. If the config exits in the kubeconfig file the information will be updated. + Create or update an admin kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 2 months. If the config exits in the kubeconfig file the information will be updated. $ stackit ske kubeconfig create my-cluster --expiration 2M - Create or update a kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath. If the config exits in the kubeconfig file the information will be updated. - $ stackit ske kubeconfig create my-cluster --filepath /path/to/config - - Get a kubeconfig for the SKE cluster with name "my-cluster" without writing it to a file and format the output as json + Get an admin kubeconfig for the SKE cluster with name "my-cluster" without writing it to a file and format the output as json $ stackit ske kubeconfig create my-cluster --disable-writing --output-format json - Create a kubeconfig for the SKE cluster with name "my-cluster. It will OVERWRITE your current kubeconfig file." + Create an admin kubeconfig for the SKE cluster with name "my-cluster". It will OVERWRITE your current kubeconfig file. $ stackit ske kubeconfig create my-cluster --overwrite true ``` @@ -47,9 +50,10 @@ stackit ske kubeconfig create CLUSTER_NAME [flags] ``` --disable-writing Disable the writing of kubeconfig. Set the output format to json or yaml using the --output-format flag to display the kubeconfig. -e, --expiration string Expiration time for the kubeconfig in seconds(s), minutes(m), hours(h), days(d) or months(M). Example: 30d. By default, expiration time is 1h - --filepath string Path to create the kubeconfig file. By default, the kubeconfig is created as 'config' in the .kube folder, in the user's home directory. + --filepath string Path to create the kubeconfig file. Will fall back to KUBECONFIG env variable if not set. In case both aren't set, the kubeconfig is created as file named 'config' in the .kube folder in the user's home directory. -h, --help Help for "stackit ske kubeconfig create" - -l, --login Create a login kubeconfig that obtains valid credentials via the STACKIT CLI. This flag is mutually exclusive with the expiration flag. + --idp Create a non-admin kubeconfig that uses the STACKIT IDP to obtain credentials. + -l, --login Create a short-lived admin kubeconfig that obtains valid credentials via the STACKIT CLI. This flag is mutually exclusive with the expiration flag. --overwrite Overwrite the kubeconfig file. ``` diff --git a/docs/stackit_ske_kubeconfig_login.md b/docs/stackit_ske_kubeconfig_login.md index 0b5441533..2b9956717 100644 --- a/docs/stackit_ske_kubeconfig_login.md +++ b/docs/stackit_ske_kubeconfig_login.md @@ -5,8 +5,8 @@ Login plugin for kubernetes clients ### Synopsis Login plugin for kubernetes clients, that creates short-lived credentials to authenticate against a STACKIT Kubernetes Engine (SKE) cluster. -First you need to obtain a kubeconfig for use with the login command (first example). -Secondly you use the kubeconfig with your chosen Kubernetes client (second example), the client will automatically retrieve the credentials via the STACKIT CLI. +First you need to obtain a kubeconfig for use with the login command (first or second example). +Secondly you use the kubeconfig with your chosen Kubernetes client (third example), the client will automatically retrieve the credentials via the STACKIT CLI. ``` stackit ske kubeconfig login [flags] @@ -15,9 +15,12 @@ stackit ske kubeconfig login [flags] ### Examples ``` - Get a login kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command. + Get an admin, login kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid admin credentials via the `stackit ske kubeconfig login` command. $ stackit ske kubeconfig create my-cluster --login + Get an IDP kubeconfig for the SKE cluster with name "my-cluster". This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command. + $ stackit ske kubeconfig create my-cluster --idp + Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl. $ kubectl cluster-info $ kubectl get pods @@ -27,6 +30,7 @@ stackit ske kubeconfig login [flags] ``` -h, --help Help for "stackit ske kubeconfig login" + --idp Use the STACKIT IdP for authentication to the cluster. ``` ### Options inherited from parent commands diff --git a/docs/stackit_ske_options.md b/docs/stackit_ske_options.md index 76afbe93c..2590f989c 100644 --- a/docs/stackit_ske_options.md +++ b/docs/stackit_ske_options.md @@ -4,6 +4,7 @@ Lists SKE provider options ### Synopsis +Command "options" is deprecated, use the subcommands instead. Lists 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. @@ -11,28 +12,10 @@ 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 + -h, --help Help for "stackit ske options" ``` ### Options inherited from parent commands @@ -49,4 +32,9 @@ stackit ske options [flags] ### SEE ALSO * [stackit ske](./stackit_ske.md) - Provides functionality for SKE +* [stackit ske options availability-zones](./stackit_ske_options_availability-zones.md) - Lists SKE provider options for availability-zones +* [stackit ske options kubernetes-versions](./stackit_ske_options_kubernetes-versions.md) - Lists SKE provider options for kubernetes-versions +* [stackit ske options machine-images](./stackit_ske_options_machine-images.md) - Lists SKE provider options for machine-images +* [stackit ske options machine-types](./stackit_ske_options_machine-types.md) - Lists SKE provider options for machine-types +* [stackit ske options volume-types](./stackit_ske_options_volume-types.md) - Lists SKE provider options for volume-types diff --git a/docs/stackit_ske_options_availability-zones.md b/docs/stackit_ske_options_availability-zones.md new file mode 100644 index 000000000..4bf77c67f --- /dev/null +++ b/docs/stackit_ske_options_availability-zones.md @@ -0,0 +1,40 @@ +## stackit ske options availability-zones + +Lists SKE provider options for availability-zones + +### Synopsis + +Lists STACKIT Kubernetes Engine (SKE) provider options for availability-zones. + +``` +stackit ske options availability-zones [flags] +``` + +### Examples + +``` + List SKE options for availability-zones + $ stackit ske options availability-zones +``` + +### Options + +``` + -h, --help Help for "stackit ske options availability-zones" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske options](./stackit_ske_options.md) - Lists SKE provider options + diff --git a/docs/stackit_ske_options_kubernetes-versions.md b/docs/stackit_ske_options_kubernetes-versions.md new file mode 100644 index 000000000..a2dd50edd --- /dev/null +++ b/docs/stackit_ske_options_kubernetes-versions.md @@ -0,0 +1,44 @@ +## stackit ske options kubernetes-versions + +Lists SKE provider options for kubernetes-versions + +### Synopsis + +Lists STACKIT Kubernetes Engine (SKE) provider options for kubernetes-versions. + +``` +stackit ske options kubernetes-versions [flags] +``` + +### Examples + +``` + List SKE options for kubernetes-versions + $ stackit ske options kubernetes-versions + + List SKE options for supported kubernetes-versions + $ stackit ske options kubernetes-versions --supported +``` + +### Options + +``` + -h, --help Help for "stackit ske options kubernetes-versions" + --supported List supported versions only +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske options](./stackit_ske_options.md) - Lists SKE provider options + diff --git a/docs/stackit_ske_options_machine-images.md b/docs/stackit_ske_options_machine-images.md new file mode 100644 index 000000000..f6deb67db --- /dev/null +++ b/docs/stackit_ske_options_machine-images.md @@ -0,0 +1,40 @@ +## stackit ske options machine-images + +Lists SKE provider options for machine-images + +### Synopsis + +Lists STACKIT Kubernetes Engine (SKE) provider options for machine-images. + +``` +stackit ske options machine-images [flags] +``` + +### Examples + +``` + List SKE options for machine-images + $ stackit ske options machine-images +``` + +### Options + +``` + -h, --help Help for "stackit ske options machine-images" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske options](./stackit_ske_options.md) - Lists SKE provider options + diff --git a/docs/stackit_ske_options_machine-types.md b/docs/stackit_ske_options_machine-types.md new file mode 100644 index 000000000..333384fc3 --- /dev/null +++ b/docs/stackit_ske_options_machine-types.md @@ -0,0 +1,40 @@ +## stackit ske options machine-types + +Lists SKE provider options for machine-types + +### Synopsis + +Lists STACKIT Kubernetes Engine (SKE) provider options for machine-types. + +``` +stackit ske options machine-types [flags] +``` + +### Examples + +``` + List SKE options for machine-types + $ stackit ske options machine-types +``` + +### Options + +``` + -h, --help Help for "stackit ske options machine-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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske options](./stackit_ske_options.md) - Lists SKE provider options + diff --git a/docs/stackit_ske_options_volume-types.md b/docs/stackit_ske_options_volume-types.md new file mode 100644 index 000000000..aeea921dc --- /dev/null +++ b/docs/stackit_ske_options_volume-types.md @@ -0,0 +1,40 @@ +## stackit ske options volume-types + +Lists SKE provider options for volume-types + +### Synopsis + +Lists STACKIT Kubernetes Engine (SKE) provider options for volume-types. + +``` +stackit ske options volume-types [flags] +``` + +### Examples + +``` + List SKE options for volume-types + $ stackit ske options volume-types +``` + +### Options + +``` + -h, --help Help for "stackit ske options 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit ske options](./stackit_ske_options.md) - Lists SKE provider options + diff --git a/docs/stackit_volume.md b/docs/stackit_volume.md index c83878554..3412504c2 100644 --- a/docs/stackit_volume.md +++ b/docs/stackit_volume.md @@ -30,11 +30,13 @@ stackit volume [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups * [stackit volume create](./stackit_volume_create.md) - Creates a volume * [stackit volume delete](./stackit_volume_delete.md) - Deletes a volume * [stackit volume describe](./stackit_volume_describe.md) - Shows details of a volume * [stackit volume list](./stackit_volume_list.md) - Lists all volumes of a project * [stackit volume performance-class](./stackit_volume_performance-class.md) - Provides functionality for volume performance classes available inside a project * [stackit volume resize](./stackit_volume_resize.md) - Resizes a volume +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots * [stackit volume update](./stackit_volume_update.md) - Updates a volume diff --git a/docs/stackit_volume_backup.md b/docs/stackit_volume_backup.md new file mode 100644 index 000000000..f6390f385 --- /dev/null +++ b/docs/stackit_volume_backup.md @@ -0,0 +1,39 @@ +## stackit volume backup + +Provides functionality for volume backups + +### Synopsis + +Provides functionality for volume backups. + +``` +stackit volume backup [flags] +``` + +### Options + +``` + -h, --help Help for "stackit volume backup" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume](./stackit_volume.md) - Provides functionality for volumes +* [stackit volume backup create](./stackit_volume_backup_create.md) - Creates a backup from a specific source +* [stackit volume backup delete](./stackit_volume_backup_delete.md) - Deletes a backup +* [stackit volume backup describe](./stackit_volume_backup_describe.md) - Describes a backup +* [stackit volume backup list](./stackit_volume_backup_list.md) - Lists all backups +* [stackit volume backup restore](./stackit_volume_backup_restore.md) - Restores a backup +* [stackit volume backup update](./stackit_volume_backup_update.md) - Updates a backup + diff --git a/docs/stackit_volume_backup_create.md b/docs/stackit_volume_backup_create.md new file mode 100644 index 000000000..5a322f34a --- /dev/null +++ b/docs/stackit_volume_backup_create.md @@ -0,0 +1,50 @@ +## stackit volume backup create + +Creates a backup from a specific source + +### Synopsis + +Creates a backup from a specific source (volume or snapshot). + +``` +stackit volume backup create [flags] +``` + +### Examples + +``` + Create a backup from a volume + $ stackit volume backup create --source-id xxx --source-type volume + + Create a backup from a snapshot with a name + $ stackit volume backup create --source-id xxx --source-type snapshot --name my-backup + + Create a backup with labels + $ stackit volume backup create --source-id xxx --source-type volume --labels key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume backup create" + --labels stringToString Key-value string pairs as labels (default []) + --name string Name of the backup + --source-id string ID of the source from which a backup should be created + --source-type string Source type of the backup, one of ["volume" "snapshot"] +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_delete.md b/docs/stackit_volume_backup_delete.md new file mode 100644 index 000000000..5300f7854 --- /dev/null +++ b/docs/stackit_volume_backup_delete.md @@ -0,0 +1,40 @@ +## stackit volume backup delete + +Deletes a backup + +### Synopsis + +Deletes a backup by its ID. + +``` +stackit volume backup delete BACKUP_ID [flags] +``` + +### Examples + +``` + Delete a backup with ID "xxx" + $ stackit volume backup delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit volume backup 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_describe.md b/docs/stackit_volume_backup_describe.md new file mode 100644 index 000000000..dbff5e4dc --- /dev/null +++ b/docs/stackit_volume_backup_describe.md @@ -0,0 +1,43 @@ +## stackit volume backup describe + +Describes a backup + +### Synopsis + +Describes a backup by its ID. + +``` +stackit volume backup describe BACKUP_ID [flags] +``` + +### Examples + +``` + Get details of a backup with ID "xxx" + $ stackit volume backup describe xxx + + Get details of a backup with ID "xxx" in JSON format + $ stackit volume backup describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit volume backup 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_list.md b/docs/stackit_volume_backup_list.md new file mode 100644 index 000000000..91f3ca99a --- /dev/null +++ b/docs/stackit_volume_backup_list.md @@ -0,0 +1,51 @@ +## stackit volume backup list + +Lists all backups + +### Synopsis + +Lists all backups in a project. + +``` +stackit volume backup list [flags] +``` + +### Examples + +``` + List all backups + $ stackit volume backup list + + List all backups in JSON format + $ stackit volume backup list --output-format json + + List up to 10 backups + $ stackit volume backup list --limit 10 + + List backups with specific labels + $ stackit volume backup list --label-selector key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume backup list" + --label-selector string Filter backups by labels + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_restore.md b/docs/stackit_volume_backup_restore.md new file mode 100644 index 000000000..80dc563db --- /dev/null +++ b/docs/stackit_volume_backup_restore.md @@ -0,0 +1,40 @@ +## stackit volume backup restore + +Restores a backup + +### Synopsis + +Restores a backup by its ID. + +``` +stackit volume backup restore BACKUP_ID [flags] +``` + +### Examples + +``` + Restore a backup with ID "xxx" + $ stackit volume backup restore xxx +``` + +### Options + +``` + -h, --help Help for "stackit volume backup restore" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_update.md b/docs/stackit_volume_backup_update.md new file mode 100644 index 000000000..02f86f4e8 --- /dev/null +++ b/docs/stackit_volume_backup_update.md @@ -0,0 +1,45 @@ +## stackit volume backup update + +Updates a backup + +### Synopsis + +Updates a backup by its ID. + +``` +stackit volume backup update BACKUP_ID [flags] +``` + +### Examples + +``` + Update the name of a backup with ID "xxx" + $ stackit volume backup update xxx --name new-name + + Update the labels of a backup with ID "xxx" + $ stackit volume backup update xxx --labels key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume backup update" + --labels stringToString Key-value string pairs as labels (default []) + --name string Name of the backup +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_snapshot.md b/docs/stackit_volume_snapshot.md new file mode 100644 index 000000000..61f6f428e --- /dev/null +++ b/docs/stackit_volume_snapshot.md @@ -0,0 +1,38 @@ +## stackit volume snapshot + +Provides functionality for snapshots + +### Synopsis + +Provides functionality for snapshots. + +``` +stackit volume snapshot [flags] +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot" +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume](./stackit_volume.md) - Provides functionality for volumes +* [stackit volume snapshot create](./stackit_volume_snapshot_create.md) - Creates a snapshot from a volume +* [stackit volume snapshot delete](./stackit_volume_snapshot_delete.md) - Deletes a snapshot +* [stackit volume snapshot describe](./stackit_volume_snapshot_describe.md) - Describes a snapshot +* [stackit volume snapshot list](./stackit_volume_snapshot_list.md) - Lists all snapshots +* [stackit volume snapshot update](./stackit_volume_snapshot_update.md) - Updates a snapshot + diff --git a/docs/stackit_volume_snapshot_create.md b/docs/stackit_volume_snapshot_create.md new file mode 100644 index 000000000..4ed86ad39 --- /dev/null +++ b/docs/stackit_volume_snapshot_create.md @@ -0,0 +1,49 @@ +## stackit volume snapshot create + +Creates a snapshot from a volume + +### Synopsis + +Creates a snapshot from a volume. + +``` +stackit volume snapshot create [flags] +``` + +### Examples + +``` + Create a snapshot from a volume with ID "xxx" + $ stackit volume snapshot create --volume-id xxx + + Create a snapshot from a volume with ID "xxx" and name "my-snapshot" + $ stackit volume snapshot create --volume-id xxx --name my-snapshot + + Create a snapshot from a volume with ID "xxx" and labels + $ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot create" + --labels stringToString Key-value string pairs as labels (default []) + --name string Name of the snapshot + --volume-id string ID of the volume from which a snapshot should be created +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/docs/stackit_volume_snapshot_delete.md b/docs/stackit_volume_snapshot_delete.md new file mode 100644 index 000000000..df9a37828 --- /dev/null +++ b/docs/stackit_volume_snapshot_delete.md @@ -0,0 +1,40 @@ +## stackit volume snapshot delete + +Deletes a snapshot + +### Synopsis + +Deletes a snapshot by its ID. + +``` +stackit volume snapshot delete SNAPSHOT_ID [flags] +``` + +### Examples + +``` + Delete a snapshot with ID "xxx" + $ stackit volume snapshot delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/docs/stackit_volume_snapshot_describe.md b/docs/stackit_volume_snapshot_describe.md new file mode 100644 index 000000000..5f7f256b7 --- /dev/null +++ b/docs/stackit_volume_snapshot_describe.md @@ -0,0 +1,43 @@ +## stackit volume snapshot describe + +Describes a snapshot + +### Synopsis + +Describes a snapshot by its ID. + +``` +stackit volume snapshot describe SNAPSHOT_ID [flags] +``` + +### Examples + +``` + Get details of a snapshot with ID "xxx" + $ stackit volume snapshot describe xxx + + Get details of a snapshot with ID "xxx" in JSON format + $ stackit volume snapshot describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/docs/stackit_volume_snapshot_list.md b/docs/stackit_volume_snapshot_list.md new file mode 100644 index 000000000..f4fe9dd3a --- /dev/null +++ b/docs/stackit_volume_snapshot_list.md @@ -0,0 +1,48 @@ +## stackit volume snapshot list + +Lists all snapshots + +### Synopsis + +Lists all snapshots in a project. + +``` +stackit volume snapshot list [flags] +``` + +### Examples + +``` + List all snapshots + $ stackit volume snapshot list + + List snapshots with a limit of 10 + $ stackit volume snapshot list --limit 10 + + List snapshots filtered by label + $ stackit volume snapshot list --label-selector key1=value1 +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot list" + --label-selector string Filter snapshots by labels + --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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/docs/stackit_volume_snapshot_update.md b/docs/stackit_volume_snapshot_update.md new file mode 100644 index 000000000..2b74b5ae8 --- /dev/null +++ b/docs/stackit_volume_snapshot_update.md @@ -0,0 +1,45 @@ +## stackit volume snapshot update + +Updates a snapshot + +### Synopsis + +Updates a snapshot by its ID. + +``` +stackit volume snapshot update SNAPSHOT_ID [flags] +``` + +### Examples + +``` + Update a snapshot name with ID "xxx" + $ stackit volume snapshot update xxx --name my-new-name + + Update a snapshot labels with ID "xxx" + $ stackit volume snapshot update xxx --labels key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume snapshot update" + --labels stringToString Key-value string pairs as labels (default []) + --name string Name of the snapshot +``` + +### 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" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume snapshot](./stackit_volume_snapshot.md) - Provides functionality for snapshots + diff --git a/go.mod b/go.mod index 115e36520..3c1c07cf6 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,53 @@ module github.com/stackitcloud/stackit-cli -go 1.24 +go 1.25.0 require ( github.com/fatih/color v1.18.0 - github.com/goccy/go-yaml v1.17.1 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/goccy/go-yaml v1.19.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf - github.com/jedib0t/go-pretty/v6 v6.6.7 - github.com/lmittmann/tint v1.0.7 + github.com/jedib0t/go-pretty/v6 v6.7.8 + github.com/lmittmann/tint v1.1.3 github.com/mattn/go-colorable v0.1.14 - github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.6 - github.com/spf13/viper v1.20.1 - github.com/stackitcloud/stackit-sdk-go/core v0.17.1 - github.com/stackitcloud/stackit-sdk-go/services/alb v0.2.2 - github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.2 - github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.2 - github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.1 - github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.0.0 - github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.21.1 - github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.0.3 - github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.13.2 - github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.0.1 - github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.11.3 - github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.0.2 - github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.0.2 - github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.6.2 - github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.0.2 - github.com/stackitcloud/stackit-sdk-go/services/ske v0.22.2 - github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.0.2 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 + github.com/stackitcloud/stackit-sdk-go/core v0.23.0 + github.com/stackitcloud/stackit-sdk-go/services/alb v0.10.0 + github.com/stackitcloud/stackit-sdk-go/services/authorization v0.12.0 + github.com/stackitcloud/stackit-sdk-go/services/cdn v1.10.0 + github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.6 + github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.3 + github.com/stackitcloud/stackit-sdk-go/services/git v0.10.3 + github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5 + github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.4 + github.com/stackitcloud/stackit-sdk-go/services/logs v0.5.2 + github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.8 + github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.6 + github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.3.5 + github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.18.5 + github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.4.3 + github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.14.3 + github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.8 + github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6 + github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0 + github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.7 + github.com/stackitcloud/stackit-sdk-go/services/ske v1.11.0 + github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.3 github.com/zalando/go-keyring v0.2.6 - golang.org/x/mod v0.24.0 - golang.org/x/oauth2 v0.30.0 - golang.org/x/term v0.32.0 - golang.org/x/text v0.25.0 - k8s.io/apimachinery v0.32.3 - k8s.io/client-go v0.32.3 + golang.org/x/mod v0.33.0 + golang.org/x/oauth2 v0.35.0 + golang.org/x/term v0.40.0 + golang.org/x/text v0.34.0 + k8s.io/apimachinery v0.35.2 + k8s.io/client-go v0.35.1 ) require ( - golang.org/x/net v0.39.0 // indirect + golang.org/x/net v0.49.0 // indirect golang.org/x/time v0.11.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect ) @@ -51,49 +56,63 @@ require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect al.essio.dev/pkg/shellescape v1.5.1 // indirect - github.com/4meepo/tagalign v1.4.2 // indirect - github.com/Abirdcfly/dupword v0.1.3 // indirect - github.com/Antonboom/errname v1.1.0 // indirect - github.com/Antonboom/nilnil v1.1.0 // indirect - github.com/Antonboom/testifylint v1.6.1 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect - github.com/Crocmagnon/fatcontext v0.7.1 // indirect - github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect - github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 // indirect - github.com/Masterminds/semver/v3 v3.3.1 // indirect + codeberg.org/chavacava/garif v0.2.0 // indirect + codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect + dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect + dev.gaijin.team/go/golib v0.6.0 // indirect + github.com/4meepo/tagalign v1.4.3 // indirect + github.com/Abirdcfly/dupword v0.1.7 // indirect + github.com/AdminBenni/iota-mixing v1.0.0 // indirect + github.com/AlwxSin/noinlineerr v1.0.5 // indirect + github.com/Antonboom/errname v1.1.1 // indirect + github.com/Antonboom/nilnil v1.1.1 // indirect + github.com/Antonboom/testifylint v1.6.4 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/Djarvur/go-err113 v0.1.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/MirrexOne/unqueryvet v1.4.0 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/alecthomas/chroma/v2 v2.21.1 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect - github.com/alexkohler/nakedret/v2 v2.0.5 // indirect - github.com/alexkohler/prealloc v1.0.0 // indirect + github.com/alexkohler/nakedret/v2 v2.0.6 // indirect + github.com/alexkohler/prealloc v1.0.1 // indirect + github.com/alfatraining/structtag v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/alingse/nilnesserr v0.2.0 // indirect - github.com/ashanbrown/forbidigo v1.6.0 // indirect - github.com/ashanbrown/makezero v1.2.0 // indirect + github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect + github.com/ashanbrown/makezero/v2 v2.1.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect github.com/bombsimon/wsl/v4 v4.7.0 // indirect + github.com/bombsimon/wsl/v5 v5.3.0 // indirect github.com/breml/bidichk v0.3.3 // indirect github.com/breml/errchkjson v0.4.1 // indirect github.com/butuzov/ireturn v0.4.0 // indirect github.com/butuzov/mirror v1.3.0 // indirect - github.com/catenacyber/perfsprint v0.9.1 // indirect - github.com/ccojocar/zxcvbn-go v1.0.2 // indirect + github.com/catenacyber/perfsprint v0.10.1 // indirect + github.com/ccojocar/zxcvbn-go v1.0.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charithe/durationcheck v0.0.10 // indirect - github.com/chavacava/garif v0.1.0 // indirect + github.com/charithe/durationcheck v0.0.11 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect - github.com/daixiang0/gci v0.13.6 // indirect + github.com/daixiang0/gci v0.13.7 // indirect + github.com/dave/dst v0.27.3 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect - github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect - github.com/ghostiam/protogetter v0.3.15 // indirect - github.com/go-critic/go-critic v0.13.0 // indirect + github.com/ghostiam/protogetter v0.3.18 // indirect + github.com/go-critic/go-critic v0.14.3 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -101,67 +120,74 @@ require ( github.com/go-toolsmith/astp v1.1.0 // indirect github.com/go-toolsmith/strparse v1.1.0 // indirect github.com/go-toolsmith/typep v1.1.0 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/gofrs/flock v0.12.1 // indirect - github.com/golang/protobuf v1.5.4 // indirect + github.com/godoc-lint/godoc-lint v0.11.1 // indirect + github.com/gofrs/flock v0.13.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golangci/asciicheck v0.5.0 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect - github.com/golangci/go-printf-func-name v0.1.0 // indirect + github.com/golangci/go-printf-func-name v0.1.1 // indirect github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect - github.com/golangci/golangci-lint v1.64.8 // indirect - github.com/golangci/misspell v0.6.0 // indirect - github.com/golangci/plugin-module-register v0.1.1 // indirect + github.com/golangci/golangci-lint/v2 v2.8.0 // indirect + github.com/golangci/golines v0.14.0 // indirect + github.com/golangci/misspell v0.7.0 // indirect + github.com/golangci/plugin-module-register v0.1.2 // indirect github.com/golangci/revgrep v0.8.0 // indirect - github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect - github.com/gordonklaus/ineffassign v0.1.0 // indirect + github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect + github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/gordonklaus/ineffassign v0.2.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect - github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/gostaticanalysis/nilerr v0.1.2 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect - github.com/jgautheron/goconst v1.7.1 // indirect + github.com/jgautheron/goconst v1.8.2 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect - github.com/jjti/go-spancheck v0.6.4 // indirect + github.com/jjti/go-spancheck v0.6.5 // indirect github.com/julz/importas v0.2.0 // indirect - github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect + github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect github.com/kisielk/errcheck v1.9.0 // indirect github.com/kkHAIKE/contextcheck v1.1.6 // indirect - github.com/kulti/thelper v0.6.3 // indirect - github.com/kunwardeep/paralleltest v1.0.14 // indirect + github.com/kulti/thelper v0.7.1 // indirect + github.com/kunwardeep/paralleltest v1.0.15 // indirect github.com/lasiar/canonicalheader v1.1.2 // indirect - github.com/ldez/exptostd v0.4.3 // indirect - github.com/ldez/gomoddirectives v0.6.1 // indirect - github.com/ldez/grignotin v0.9.0 // indirect - github.com/ldez/tagliatelle v0.7.1 // indirect - github.com/ldez/usetesting v0.4.3 // indirect + github.com/ldez/exptostd v0.4.5 // indirect + github.com/ldez/gomoddirectives v0.8.0 // indirect + github.com/ldez/grignotin v0.10.1 // indirect + github.com/ldez/structtags v0.6.1 // indirect + github.com/ldez/tagliatelle v0.7.2 // indirect + github.com/ldez/usetesting v0.5.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/macabu/inamedparam v0.2.0 // indirect - github.com/maratori/testableexamples v1.0.0 // indirect - github.com/maratori/testpackage v1.1.1 // indirect + github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect + github.com/manuelarte/funcorder v0.5.0 // indirect + github.com/maratori/testableexamples v1.0.1 // indirect + github.com/maratori/testpackage v1.1.2 // indirect github.com/matoous/godox v1.1.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect - github.com/mgechev/revive v1.9.0 // indirect + github.com/mgechev/revive v1.13.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moricho/tparallel v0.3.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect - github.com/nunnatsa/ginkgolinter v0.19.1 // indirect - github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/nunnatsa/ginkgolinter v0.21.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/polyfloyd/go-errorlint v1.8.0 // indirect github.com/prometheus/client_golang v1.12.1 // indirect github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/procfs v0.7.3 // indirect - github.com/quasilyte/go-ruleguard v0.4.4 // indirect - github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect + github.com/quasilyte/go-ruleguard v0.4.5 // indirect + github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect github.com/quasilyte/gogrep v0.5.0 // indirect github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect @@ -170,88 +196,95 @@ require ( github.com/ryancurrah/gomodguard v1.4.1 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sashamelentyev/interfacebloat v1.1.0 // indirect - github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect - github.com/securego/gosec/v2 v2.22.3 // indirect + github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect + github.com/securego/gosec/v2 v2.22.11 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sivchari/containedctx v1.0.3 // indirect - github.com/sivchari/tenv v1.12.1 // indirect - github.com/sonatard/noctx v0.1.0 // indirect + github.com/sonatard/noctx v0.4.0 // indirect github.com/sourcegraph/go-diff v0.7.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect - github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect + github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.10.0 // indirect - github.com/tdakkota/asciicheck v0.4.1 // indirect - github.com/tetafro/godot v1.5.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tetafro/godot v1.5.4 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect - github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect + github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect github.com/ultraware/funlen v0.2.0 // indirect github.com/ultraware/whitespace v0.2.0 // indirect github.com/uudashr/gocognit v1.2.0 // indirect - github.com/uudashr/iface v1.3.1 // indirect + github.com/uudashr/iface v1.4.1 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xen0n/gosmopolitan v1.3.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect - go-simpler.org/musttag v0.13.1 // indirect - go-simpler.org/sloglint v0.11.0 // indirect - go.uber.org/atomic v1.9.0 // indirect + go-simpler.org/musttag v0.14.0 // indirect + go-simpler.org/sloglint v0.11.1 // indirect + go.augendre.info/arangolint v0.3.1 // indirect + go.augendre.info/fatcontext v0.9.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect - go.uber.org/zap v1.24.0 // indirect - golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/sync v0.14.0 // indirect - golang.org/x/tools v0.32.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect honnef.co/go/tools v0.6.1 // indirect - mvdan.cc/gofumpt v0.8.0 // indirect - mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + mvdan.cc/gofumpt v0.9.2 // indirect + mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/gofuzz v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.7.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.14.0 // indirect - github.com/spf13/cast v1.7.1 // indirect - github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.2 - github.com/stackitcloud/stackit-sdk-go/services/logme v0.22.1 - github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.22.1 - github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.1.2 - github.com/stackitcloud/stackit-sdk-go/services/observability v0.5.1 - github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.22.1 - github.com/stackitcloud/stackit-sdk-go/services/redis v0.22.1 + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.2 + github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.8.0 + github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.6 + github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.6 + github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.7.0 + github.com/stackitcloud/stackit-sdk-go/services/observability v0.17.0 + github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.26.0 + github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.6 + github.com/stackitcloud/stackit-sdk-go/services/sfs v0.4.0 github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sys v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.32.3 // indirect + k8s.io/api v0.35.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect - sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) -tool github.com/golangci/golangci-lint/cmd/golangci-lint +tool ( + github.com/golangci/golangci-lint/v2/cmd/golangci-lint + golang.org/x/tools/cmd/goimports +) diff --git a/go.sum b/go.sum index babf8358f..23665a67e 100644 --- a/go.sum +++ b/go.sum @@ -36,56 +36,70 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= +codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= +codeberg.org/polyfloyd/go-errorlint v1.9.0 h1:VkdEEmA1VBpH6ecQoMR4LdphVI3fA4RrCh2an7YmodI= +codeberg.org/polyfloyd/go-errorlint v1.9.0/go.mod h1:GPRRu2LzVijNn4YkrZYJfatQIdS+TrcK8rL5Xs24qw8= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= +dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= +dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= -github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= -github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= -github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw= -github.com/Antonboom/errname v1.1.0 h1:A+ucvdpMwlo/myWrkHEUEBWc/xuXdud23S8tmTb/oAE= -github.com/Antonboom/errname v1.1.0/go.mod h1:O1NMrzgUcVBGIfi3xlVuvX8Q/VP/73sseCaAppfjqZw= -github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng= -github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE= -github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o= -github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI= +github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= +github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= +github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ= +github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4= +github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= +github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= +github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= +github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= +github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q= +github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ= +github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ= +github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= +github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= +github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Crocmagnon/fatcontext v0.7.1 h1:SC/VIbRRZQeQWj/TcQBS6JmrXcfA+BU4OGSVUt54PjM= -github.com/Crocmagnon/fatcontext v0.7.1/go.mod h1:1wMvv3NXEBJucFGfwOJBxSVWcoIO6emV215SMkW9MFU= -github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= -github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= -github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k= -github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg= -github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= -github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= +github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ= +github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg= github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA= +github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= -github.com/alexkohler/nakedret/v2 v2.0.5 h1:fP5qLgtwbx9EJE8dGEERT02YwS8En4r9nnZ71RK+EVU= -github.com/alexkohler/nakedret/v2 v2.0.5/go.mod h1:bF5i0zF2Wo2o4X4USt9ntUWve6JbFv02Ff4vlkmS/VU= -github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= -github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ= +github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= +github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE= +github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig= +github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= +github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= -github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= -github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= -github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU= -github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4= -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= -github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo= +github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c= +github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE= +github.com/ashanbrown/makezero/v2 v2.1.0/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -96,6 +110,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= +github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM= +github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I= github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= @@ -104,19 +120,27 @@ github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= -github.com/catenacyber/perfsprint v0.9.1 h1:5LlTp4RwTooQjJCvGEFV6XksZvWE7wCOUvjD2z0vls0= -github.com/catenacyber/perfsprint v0.9.1/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM= -github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg= -github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60= +github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= +github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4= -github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= -github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= -github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= +github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= +github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -128,10 +152,14 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= -github.com/daixiang0/gci v0.13.6 h1:RKuEOSkGpSadkGbvZ6hJ4ddItT3cVZ9Vn9Rybk6xjl8= -github.com/daixiang0/gci v0.13.6/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= +github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= +github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= +github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= +github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= 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= @@ -140,8 +168,8 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -156,16 +184,16 @@ github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47A github.com/firefart/nonamedreturns v1.0.6/go.mod h1:R8NisJnSIpvPWheCq0mNRXJok6D8h7fagJTF8EMEwCo= 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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= -github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= -github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= -github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= -github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY= -github.com/go-critic/go-critic v0.13.0/go.mod h1:M/YeuJ3vOCQDnP2SU+ZhjgRzwzcBW87JqLpMJLrZDLI= +github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo= +github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI= +github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog= +github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -175,8 +203,8 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= @@ -207,23 +235,23 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= -github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA= +github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -251,28 +279,34 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 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.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= +github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= -github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU= -github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s= +github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= +github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= -github.com/golangci/golangci-lint v1.64.8 h1:y5TdeVidMtBGG32zgSC7ZXTFNHrsJkDnpO4ItB3Am+I= -github.com/golangci/golangci-lint v1.64.8/go.mod h1:5cEsUQBSr6zi8XI8OjmcY2Xmliqc4iYL7YoPrL+zLJ4= -github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs= -github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo= -github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c= -github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc= +github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA= +github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI= +github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc= +github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ= +github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c= +github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= -github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed h1:IURFTjxeTfNFP0hTEi1YKjB/ub8zkpaOqFFMApi2EAs= -github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed/go.mod h1:XLXN8bNw4CGRPaqgl3bv/lhz7bsGPh4/xSaMTbo2vkQ= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= +github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= -github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -283,14 +317,10 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= @@ -300,8 +330,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= +github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= @@ -309,18 +339,17 @@ 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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= -github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= +github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= -github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= -github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= -github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= +github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= +github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= @@ -329,8 +358,8 @@ github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1T github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= -github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= +github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -342,14 +371,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= -github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo= -github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= -github.com/jgautheron/goconst v1.7.1 h1:VpdAG7Ca7yvvJk5n8dMwQhfEZJh95kl/Hl9S1OI5Jkk= -github.com/jgautheron/goconst v1.7.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= +github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o= +github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= +github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= +github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= -github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc= -github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk= +github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= +github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -364,9 +393,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= -github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI= -github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= +github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -382,34 +410,42 @@ 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/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= -github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= -github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8= -github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= +github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= +github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= +github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= +github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= -github.com/ldez/exptostd v0.4.3 h1:Ag1aGiq2epGePuRJhez2mzOpZ8sI9Gimcb4Sb3+pk9Y= -github.com/ldez/exptostd v0.4.3/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ= -github.com/ldez/gomoddirectives v0.6.1 h1:Z+PxGAY+217f/bSGjNZr/b2KTXcyYLgiWI6geMBN2Qc= -github.com/ldez/gomoddirectives v0.6.1/go.mod h1:cVBiu3AHR9V31em9u2kwfMKD43ayN5/XDgr+cdaFaKs= -github.com/ldez/grignotin v0.9.0 h1:MgOEmjZIVNn6p5wPaGp/0OKWyvq42KnzAt/DAb8O4Ow= -github.com/ldez/grignotin v0.9.0/go.mod h1:uaVTr0SoZ1KBii33c47O1M8Jp3OP3YDwhZCmzT9GHEk= -github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk= -github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I= -github.com/ldez/usetesting v0.4.3 h1:pJpN0x3fMupdTf/IapYjnkhiY1nSTN+pox1/GyBRw3k= -github.com/ldez/usetesting v0.4.3/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ= +github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= +github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= +github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk= +github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q= +github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= +github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= +github.com/ldez/structtags v0.6.1 h1:bUooFLbXx41tW8SvkfwfFkkjPYvFFs59AAMgVg6DUBk= +github.com/ldez/structtags v0.6.1/go.mod h1:YDxVSgDy/MON6ariaxLF2X09bh19qL7MtGBN5MrvbdY= +github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= +github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= +github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= +github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= -github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y= -github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= +github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddBCpE= github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= -github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= -github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= -github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= +github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8= +github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA= +github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= +github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= +github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= +github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= @@ -418,13 +454,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mgechev/revive v1.9.0 h1:8LaA62XIKrb8lM6VsBSQ92slt/o92z5+hTw3CmrvSrM= -github.com/mgechev/revive v1.9.0/go.mod h1:LAPq3+MgOf7GcL5PlWIkHb0PT7XH4NuC2LdWymhb9Mo= +github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM= +github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -432,10 +467,13 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -446,14 +484,12 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= -github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4= -github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= -github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0= -github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= -github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= -github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po= +github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= @@ -465,13 +501,10 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= -github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= @@ -496,10 +529,10 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/quasilyte/go-ruleguard v0.4.4 h1:53DncefIeLX3qEpjzlS1lyUmQoUEeOWPFWqaTJq9eAQ= -github.com/quasilyte/go-ruleguard v0.4.4/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= -github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= -github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= +github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= +github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= +github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= @@ -520,18 +553,20 @@ github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I= github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU= github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= -github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= -github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= -github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ= -github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8= -github.com/securego/gosec/v2 v2.22.3 h1:mRrCNmRF2NgZp4RJ8oJ6yPJ7G4x6OCiAXHd8x4trLRc= -github.com/securego/gosec/v2 v2.22.3/go.mod h1:42M9Xs0v1WseinaB/BmNGO8AVqG8vRfhC2686ACY48k= +github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= +github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= +github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg= +github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -541,109 +576,114 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= -github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY= -github.com/sivchari/tenv v1.12.1/go.mod h1:1LjSOUCc25snIr5n3DtGGrENhX3LuWefcplwVGC24mw= -github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM= -github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c= -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/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o= +github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= -github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= -github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= -github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= -github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= -github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= -github.com/stackitcloud/stackit-sdk-go/core v0.17.1 h1:TTrVoB1lERd/qfWzpe6HpwCJSjtaGnUI7UE7ITb5IT0= -github.com/stackitcloud/stackit-sdk-go/core v0.17.1/go.mod h1:8KIw3czdNJ9sdil9QQimxjR6vHjeINFrRv0iZ67wfn0= -github.com/stackitcloud/stackit-sdk-go/services/alb v0.2.2 h1:7PIcO05sveIb0CNfJiwsWhcR7tc+9/e4p580Hm398ww= -github.com/stackitcloud/stackit-sdk-go/services/alb v0.2.2/go.mod h1:IYXv5QX/LEYfF02eN1/1KKo979yPewWhrbhpRnG2yOg= -github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.2 h1:erpN0BM7lTkV/zhxka5fTYHssQaOqdGjj76c2FWMec0= -github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.2/go.mod h1:dJ19ZwFjp2bfC5ZobXV3vUdSpE3quUw3GuoFSKLpHIo= -github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.2 h1:6rb3EM0yXuMIBd1U6WsJoMzEiVaHC3WQFWFvT23OE4Y= -github.com/stackitcloud/stackit-sdk-go/services/dns v0.13.2/go.mod h1:PMHoavoIaRZpkI9BA0nsnRjGoHASVSBon45XB3QyhMA= -github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.1 h1:JXcLcbVesTtwVVb+jJjU3o0FmSpXBRnOw6PVETaeK+E= -github.com/stackitcloud/stackit-sdk-go/services/iaas v0.22.1/go.mod h1:QNH50Pq0Hu21lLDOwa02PIjRjTl0LfEdHoz5snGQRn8= -github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.2 h1:5rVt3n7kDJvJQxFCtxfx8uZI9PGkvJY9fVJ4yar10Uc= -github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.2/go.mod h1:h3oM6cS23Yfynp8Df1hNr0FxtY5Alii/2g8Wqi5SIVE= -github.com/stackitcloud/stackit-sdk-go/services/logme v0.22.1 h1:ZeEuUf0DrTTU/acHOg+pQcpLZV1NF9SZbqI6ogjdLao= -github.com/stackitcloud/stackit-sdk-go/services/logme v0.22.1/go.mod h1:+3jizYma6Dq3XVn6EMMdSBF9eIm0w6hCJvrStB3AIL0= -github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.22.1 h1:bdfgwsFNJRqCDUu/r4ZYyACzHCo1bD3y8fGFLYvX9C4= -github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.22.1/go.mod h1:qgvi3qiAzB1wKpMJ5CPnEaUToeiwgnQxGvlkjdisaLU= -github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.0.0 h1:kUdCkqVFEOP1GCUoLwdkMi9o+qtghvBT+grIE0nvvlc= -github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.0.0/go.mod h1:+JSnz5/AvGN5ek/iH008frRc/NgjSr1EVOTIbyLwAuQ= -github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.1.2 h1:h+EwnBOflLAKTZTlnl9YoB/z3ilvW34uezJO8YNG6Bo= -github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.1.2/go.mod h1:iCOYS9yICXQPyMAIdUGMqJDLY8HXKiVAdiMzO/mPvtA= -github.com/stackitcloud/stackit-sdk-go/services/observability v0.5.1 h1:BIMl8ISsDGsFB6dBqfxQMPFVyS5gB5EcwM4Jzpg65XE= -github.com/stackitcloud/stackit-sdk-go/services/observability v0.5.1/go.mod h1:1gMNjPCqT868oIqdWGkiReS1G/qpM4bYKYBmDRi8sqg= -github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.21.1 h1:yukHymULIXiSu7RQSnYOaPlBXegFR3ICMXdQzG8v14g= -github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.21.1/go.mod h1:c30J6f/fXtbzcHkH3ZcabZUek3wfy5CRnEkcW5e5yXg= -github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.0.3 h1:IFXdEoN92c7qpvzeXqMnhz9OsiLR7Hiub7UkxK8luWo= -github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.0.3/go.mod h1:4g/L5PHfz1xk3udEhvPy2nXiH4UgRO5Cj6iwUa7k5VQ= -github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.22.1 h1:CHRxxDBW+4VrpZDyXabOJCF7LY0i+PNYY5QWE0kwGIo= -github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.22.1/go.mod h1:9JoCgKe2IKmawcuAYIuxrAMMb6pk5XiY80Z3sU7IWy0= -github.com/stackitcloud/stackit-sdk-go/services/redis v0.22.1 h1:bYIczS7iJNSQGkoSghicCC3F657TqLl4CelCgyt3tbw= -github.com/stackitcloud/stackit-sdk-go/services/redis v0.22.1/go.mod h1:mID7cr40WzI4wdvveYhLzvkk+zPfolfo5+VcDGo5slU= -github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.13.2 h1:k+Nr9JBzBBy6di7T0pmQGEKqjPkbv8goRFdWo6BaIhQ= -github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.13.2/go.mod h1:Picm0mP7TUBTAu+MzWtedz61LczMnWH4ghPjwB/g5DE= -github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.0.1 h1:VUoD+yQKwWFfgYAZd9JkjgKQCWjs0dDuf6ZgNYcXi6Y= -github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.0.1/go.mod h1:qId86UiowpDDs0L+mstdzz3xXtnW+R56wh7q8CQltb4= -github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.11.3 h1:L8uFydvDVJBdXLSQdgRwztbQewwNtTtuPhM1L1ehIvA= -github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.11.3/go.mod h1:iD7R3CNdLUDUyKP+lATsQy+V4QGMMOb15zPnNq4wnbs= -github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.0.2 h1:mf4ej9oTc45huZxRe231DAPY9MOgm9qV1gflXdYQN4Q= -github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.0.2/go.mod h1:j2GV/ePXEccwq0WV7DtmKpsZcJ2X45Be3D4oAlJdddo= -github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.0.2 h1:MCHMaS3hRTEYu+rWIDOas9tVn5+ddaulXzcE2porMek= -github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.0.2/go.mod h1:5F7/2BiKhrbvHD56mj5xR9qf8P0V2yMgeitmdIpQv4s= -github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.6.2 h1:SdQ9VqDmamOhnTp6fd0QYKhCcoxB2GP938o7hGAubqg= -github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.6.2/go.mod h1:e3WMlYcUZZ5bpndWuCrxEQqIOPsYPuus9O/EG2eIfG4= -github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.0.2 h1:KsfdYvi24Mli50gEDuruXk5lg5mQIFqr/hfLfHHFJXU= -github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.0.2/go.mod h1:TYWEik7b2aJrCJrRWU3mn1p1hmShCwizRthT3xl6z0o= -github.com/stackitcloud/stackit-sdk-go/services/ske v0.22.2 h1:SYX9IXg+2YAIdiUzLyqs9ga+S+bjhZs8WYeqU3GK3hk= -github.com/stackitcloud/stackit-sdk-go/services/ske v0.22.2/go.mod h1:nJTJ3qT2xHmOs2aqQgBPfOLp322gE9pvpRaluTlRmN8= -github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.0.2 h1:uNwgJMHRuihWJJbnCw2FiJ9zZB9ZaE9YJ8e7ytZG1Pc= -github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.0.2/go.mod h1:CyYJqR0tZWs2r5bGz69j4bmvhxdsd4QLCm1Uf+LouB0= -github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= -github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= +github.com/stackitcloud/stackit-sdk-go/core v0.23.0 h1:zPrOhf3Xe47rKRs1fg/AqKYUiJJRYjdcv+3qsS50mEs= +github.com/stackitcloud/stackit-sdk-go/core v0.23.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.10.0 h1:V9+885qkSv621rZZatg1YE5ENM1ElALxQDJsh+hDIUg= +github.com/stackitcloud/stackit-sdk-go/services/alb v0.10.0/go.mod h1:V6+MolxM/M2FWyWZA+FRFKEzzUe10MU9eEVfMvxHGi8= +github.com/stackitcloud/stackit-sdk-go/services/authorization v0.12.0 h1:HxPgBu04j5tj6nfZ2r0l6v4VXC0/tYOGe4sA5Addra8= +github.com/stackitcloud/stackit-sdk-go/services/authorization v0.12.0/go.mod h1:uYI9pHAA2g84jJN25ejFUxa0/JtfpPZqMDkctQ1BzJk= +github.com/stackitcloud/stackit-sdk-go/services/cdn v1.10.0 h1:YALzjYAApyQMKyt4C2LKhPRZHa6brmbFeKuuwl+KOTs= +github.com/stackitcloud/stackit-sdk-go/services/cdn v1.10.0/go.mod h1:915b/lJgDikYFEoRQ8wc8aCtPvUCceYk7gGm9nViJe0= +github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.6 h1:GBRb49x5Nax/oQQaaf2F3kKwv8DQQOL0TQOC0C/v/Ew= +github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.6/go.mod h1:IX9iL3MigDZUmzwswTJMfYvyi118KAHrFMfjJUy5NYk= +github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.3 h1:TxChb2qbO82JiQEBYClSSD5HZxqKeKJ6dIvkEUCJmbs= +github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.3/go.mod h1:KVWvQHb7CQLD9DzA4Np3WmakiCCsrHaCXvFEnOQ7nPk= +github.com/stackitcloud/stackit-sdk-go/services/git v0.10.3 h1:VIjkSofZz9utOOkBdNZCIb07P/JdKc1kHV1P8Rq9dLc= +github.com/stackitcloud/stackit-sdk-go/services/git v0.10.3/go.mod h1:EJk1Ss9GTel2NPIu/w3+x9XcQcEd2k3ibea5aQDzVhQ= +github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5 h1:W57+XRa8wTLsi5CV9Tqa7mGgt/PvlRM//RurXSmvII8= +github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.5/go.mod h1:lTWjW57eAq1bwfM6nsNinhoBr3MHFW/GaFasdAsYfDM= +github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.4 h1:cbXM7jUBCL7A5zxJKFWolRIDl45sdJMMMAzeumeIEOA= +github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.4/go.mod h1:z+7KKZf0uHXU/Kb4CRs/oaBrXRJ01LpiD0OH11MXLOk= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.2 h1:2ulSL2IkIAKND59eAjbEhVkOoBMyvm48ojwz1a3t0U0= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.3.2/go.mod h1:cuIaMMiHeHQsbvy7BOFMutoV3QtN+ZBx7Tg3GmYUw7s= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.8.0 h1:DxrN85V738CRLynu6MULQHO+OXyYnkhVPgoZKULfFIs= +github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.8.0/go.mod h1:ClPE4TOM1FeaJiwTXvApq4gWaSgTLq6nU3PPHAIQDN4= +github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.6 h1:4x30lC+YBmo7XpsAzTn0W+C/oP5flnLVgIh5u3O/P0o= +github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.6/go.mod h1:ewaYUiZcBTSS6urE5zEJBPCqxu70w2IjnBHCvnKdFKE= +github.com/stackitcloud/stackit-sdk-go/services/logs v0.5.2 h1:vr4atxFRT+EL+DqONMT5R44f7AzEMbePa9U7PEE0THU= +github.com/stackitcloud/stackit-sdk-go/services/logs v0.5.2/go.mod h1:CAPsiTX7osAImfrG5RnIjaJ/Iz3QpoBKuH2fS346wuQ= +github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.6 h1:Y/byRjX2u/OZl0gKS/Rau6ob2bDyv26xnw6A6JNkKJk= +github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.6/go.mod h1:sY66ZgCgBc1mScPV95ek5WtUEGYizdP1RMsGaqbdbhw= +github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.8 h1:S7t4wcT6SN8ZzdoY8d6VbF903zFpGjzqrU0FN27rJPg= +github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.8/go.mod h1:CdrhFUsBO7/iJleCc2yQjDChIbG6YaxKNBQRNCjgcF4= +github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.7.0 h1:UxnbsKm6PQV8Gudw/EhySaEh9q1xSaTG8mzJz1EvhnE= +github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.7.0/go.mod h1:RFL4h6JZvpsyFYbdJ3+eINEkletzJQTfrPdd+yPT/fU= +github.com/stackitcloud/stackit-sdk-go/services/observability v0.17.0 h1:LGwCvvST0fwUgZ6bOxYIfu45qqTgv421ZS07UhKjZL8= +github.com/stackitcloud/stackit-sdk-go/services/observability v0.17.0/go.mod h1:9KdrXC5JS30Ay3mR0adb3vNdhca+qxiy/cPF5P4wehQ= +github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.6 h1:oTVx1+O177Ojn8OvXIOUbRSwtx7L59jhxDPrZEQFOfQ= +github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.6/go.mod h1:6ZBeCCY6qG8w1oK7osf61Egyv3mp7Ahv6GDGxiarDGo= +github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.3.5 h1:H67e3KnHQx954yI8fuQmxXwRf/myqAdLg2KvxImp00g= +github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.3.5/go.mod h1:xmAWk9eom8wznvLuLfm0F4xyeiBX8LaggXsKFmos+dw= +github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.26.0 h1:/8lmviszgrB+0Cz7HdhFELyTiTeqIs7LfnI6sNX4rW8= +github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.26.0/go.mod h1:hnhvlLX1Y71R8KIQqLBeoSZqkU5ZJOG0J4wz0LeUdaw= +github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.6 h1:CXM9cZ9WeTyJd+Aw/hnJnDsKRVAQi4qgtd0RJ3zoPwo= +github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.6/go.mod h1:KJNceOHRefjku1oVBoHG7idCS/SeW42WJ+55bN3AxrQ= +github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.18.5 h1:MZ5aTO2NQ1Jecmi67ByGskve5nKXHl91fE+z+vFjxt4= +github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.18.5/go.mod h1:CJLmdqWvJm5/3+lXPDKu8k4WXs2UG8euGoqQX5xE79k= +github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.4.3 h1:AiGNJmpQ/f9cglaIQQ4SyePbtCI3K1DQLNvqVN9jKSo= +github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.4.3/go.mod h1:U/q0V89fvCF2O1ZJfi68/Chie9YY/5s7xBHI1Klq7wA= +github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.14.3 h1:3hZSg3z+4AXa5LbR2Vl38VmSA83ABItE63E53LuyWv8= +github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.14.3/go.mod h1:5unx5r0IgeFCtJDEgsWddtgKvYSw442FDNdhtfyJnQI= +github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.8 h1:LLyANBzE8sQa0/49tQBqq4sVLhNgwdqCeQm76srJHWw= +github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.8/go.mod h1:/bmg57XZu+bGczzcoumrukiGMPGzI2mOyTT4BVIQUBs= +github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6 h1:sQ3fdtUjgIL2Ul8nRYVVacHOwi5aSMTGGbYVL30oQBU= +github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.6/go.mod h1:3fjlL+9YtuI9Oocl1ZeYIK48ImtY4DwPggFhqAygr7o= +github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0 h1:l1EDIlXce2C8JcbBDHVa6nZ4SjPTqmnALTgrhms+NKI= +github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.12.0/go.mod h1:EXq8/J7t9p8zPmdIq+atuxyAbnQwxrQT18fI+Qpv98k= +github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.7 h1:M2PYLF8k3zmAwYWSKfUiCTNTXr7ROGuJganVVEQA3YI= +github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.7/go.mod h1:jitkQuP2K/SH63Qor0C4pcqz1GDCy/lK2H4t8/VDse4= +github.com/stackitcloud/stackit-sdk-go/services/sfs v0.4.0 h1:ofdGO2dGH6ywKbIVxaxRVal3jWX9WlcHSm5BTud5bC4= +github.com/stackitcloud/stackit-sdk-go/services/sfs v0.4.0/go.mod h1:r5lBwzJpJe2xBIYctkVIIpaZ41Y6vUEpkmsWR2VoQJs= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.11.0 h1:QoKyQPe8FqDqJLNgE5uRlZ/y1c1GUxjV1DDLu5QEBD8= +github.com/stackitcloud/stackit-sdk-go/services/ske v1.11.0/go.mod h1:KhVYCR58wETqdI7Quwhe3OR3BhB2T/b7DzaMsfDnr8g= +github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.3 h1:AQrcr+qeIuZob+3TT2q1L4WOPtpsu5SEpkTnOUHDqfE= +github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.3/go.mod h1:8BBGC69WFXWWmKgzSjgE4HvsI7pEgO0RN2cASwuPJ18= +github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g= +github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/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/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -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/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8= -github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8= github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= -github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4= -github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk= +github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg= +github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8= -github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU= -github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU= +github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is= +github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo= github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw= github.com/tommy-muehle/go-mnd/v2 v2.5.1/go.mod h1:WsUAkMJMYww6l/ufffCD3m+P7LEvr8TnZn9lwVDlgzw= github.com/ultraware/funlen v0.2.0 h1:gCHmCn+d2/1SemTdYMiKLAHFYxTYz7z9VIDRaTGyLkI= @@ -652,12 +692,14 @@ github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSW github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= -github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= -github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg= +github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= +github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM= github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk= github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs= @@ -677,25 +719,31 @@ gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= -go-simpler.org/musttag v0.13.1 h1:lw2sJyu7S1X8lc8zWUAdH42y+afdcCnHhWpnkWvd6vU= -go-simpler.org/musttag v0.13.1/go.mod h1:8r450ehpMLQgvpb6sg+hV5Ur47eH6olp/3yEanfG97k= -go-simpler.org/sloglint v0.11.0 h1:JlR1X4jkbeaffiyjLtymeqmGDKBDO1ikC6rjiuFAOco= -go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkyeW2Pvw= +go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= +go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= +go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s= +go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ= +go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo= +go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g= +go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= +go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= -go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -719,8 +767,8 @@ golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWB golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4= -golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE= +golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -745,13 +793,11 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -786,22 +832,20 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -817,8 +861,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -864,24 +908,23 @@ golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= 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/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -889,13 +932,11 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -933,34 +974,30 @@ golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1040,8 +1077,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= 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.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -1049,8 +1086,8 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= -gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -1058,7 +1095,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -1072,28 +1108,30 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= -k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= -k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= -k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= -k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= -k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= +k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= -k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= -k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k= -mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= -mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= -mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= +mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= -sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= -sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/golang-ci.yaml b/golang-ci.yaml index 3487f7456..3026ce631 100644 --- a/golang-ci.yaml +++ b/golang-ci.yaml @@ -1,95 +1,89 @@ -# This file contains all available configuration options -# with their default values. - -# options for analysis running +version: "2" 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: - rules: - main: - list-mode: lax # Everything is allowed unless it is denied - deny: - - pkg: "github.com/stretchr/testify" - desc: 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 + - bodyclose + - depguard - errorlint + - forcetypeassert - gochecknoinits - gocritic - - gofmt - - goimports - gosec - misspell - nakedret - revive - - depguard - - bodyclose - sqlclosecheck - wastedassign - - forcetypeassert - - errcheck disable: - noctx # false positive: finds errors with http.NewRequest that dont make sense - unparam # false positives -issues: - exclude-use-default: false + settings: + depguard: + rules: + main: + list-mode: lax + deny: + - pkg: github.com/stretchr/testify + desc: Do not use a testing framework + gocritic: + disabled-checks: + - wrapperFunc + - typeDefFirst + - ifElseChain + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - rangeValCopy + enabled-tags: + - performance + - style + - experimental + 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() + 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 + nakedret: + max-func-lines: 0 + revive: + 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 + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + # put imports beginning with prefix after 3rd-party packages; + # it's a comma-separated list of prefixes + local-prefixes: github.com/stackitcloud/stackit-cli + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/internal/cmd/affinity-groups/affinity-groups.go b/internal/cmd/affinity-groups/affinity-groups.go index 57f44f65e..0287d8713 100644 --- a/internal/cmd/affinity-groups/affinity-groups.go +++ b/internal/cmd/affinity-groups/affinity-groups.go @@ -2,16 +2,17 @@ package affinity_groups import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/affinity-groups/create" "github.com/stackitcloud/stackit-cli/internal/cmd/affinity-groups/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/affinity-groups/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/affinity-groups/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "affinity-group", Short: "Manage server affinity groups", @@ -19,15 +20,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand( - create.NewCmd(p), - delete.NewCmd(p), - describe.NewCmd(p), - list.NewCmd(p), + create.NewCmd(params), + delete.NewCmd(params), + describe.NewCmd(params), + list.NewCmd(params), ) } diff --git a/internal/cmd/affinity-groups/create/create.go b/internal/cmd/affinity-groups/create/create.go index ab1f48d3a..0a38cd35f 100644 --- a/internal/cmd/affinity-groups/create/create.go +++ b/internal/cmd/affinity-groups/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Policy string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates an affinity groups", @@ -41,25 +42,23 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit affinity-group create --name AFFINITY_GROUP_NAME --policy soft-affinity", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create the affinity group %q?", model.Name) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create the affinity group %q?", model.Name) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -70,7 +69,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create affinity group: %w", err) } if resp := result; resp != nil { - return outputResult(p, *model, *resp) + return outputResult(params.Printer, *model, *resp) } return fmt.Errorf("create affinity group: nil result") }, @@ -89,7 +88,7 @@ func configureFlags(cmd *cobra.Command) { } func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiCreateAffinityGroupRequest { - req := apiClient.CreateAffinityGroup(ctx, model.ProjectId) + req := apiClient.CreateAffinityGroup(ctx, model.ProjectId, model.Region) req = req.CreateAffinityGroupPayload( iaas.CreateAffinityGroupPayload{ Name: utils.Ptr(model.Name), @@ -99,7 +98,7 @@ func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClie return req } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -111,38 +110,18 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Policy: flags.FlagToStringValue(p, cmd, policyFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func outputResult(p *print.Printer, model inputModel, resp iaas.AffinityGroup) error { outputFormat := "" if model.GlobalFlagModel != nil { - outputFormat = model.GlobalFlagModel.OutputFormat + outputFormat = model.OutputFormat } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal affinity group: %w", err) - } - p.Outputln(string(details)) - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal affinity group: %w", err) - } - p.Outputln(string(details)) - default: + + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created affinity group %q with id %s\n", model.Name, utils.PtrString(resp.Id)) - } - return nil + return nil + }) } diff --git a/internal/cmd/affinity-groups/create/create_test.go b/internal/cmd/affinity-groups/create/create_test.go index 3ab7db59f..8cdba240a 100644 --- a/internal/cmd/affinity-groups/create/create_test.go +++ b/internal/cmd/affinity-groups/create/create_test.go @@ -4,16 +4,24 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -const projectIdFlag = globalflags.ProjectIdFlag +const ( + testName = "test-name" + testPolicy = "test-policy" + testRegion = "eu01" +) type testCtxKey struct{} @@ -23,14 +31,10 @@ var ( testProjectId = uuid.NewString() ) -const ( - testName = "test-name" - testPolicy = "test-policy" -) - func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, nameFlag: testName, policyFlag: testPolicy, @@ -46,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Name: testName, Policy: testPolicy, @@ -57,7 +62,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiCreateAffinityGroupRequest)) iaas.ApiCreateAffinityGroupRequest { - request := testClient.CreateAffinityGroup(testCtx, testProjectId) + request := testClient.CreateAffinityGroup(testCtx, testProjectId, testRegion) request = request.CreateAffinityGroupPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -79,6 +84,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateAffinityGroupPayload)) iaas func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -119,43 +125,7 @@ func TestParseInput(t *testing.T) { } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Fatalf("configure global flags: %v", err) - } - - for flag, value := range tt.flagValues { - if err := cmd.Flags().Set(flag, value); err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", flag, value, err) - } - } - - if err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -212,7 +182,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { err := outputResult(p, tt.model, tt.response) diff --git a/internal/cmd/affinity-groups/delete/delete.go b/internal/cmd/affinity-groups/delete/delete.go index 195b4539d..d37005653 100644 --- a/internal/cmd/affinity-groups/delete/delete.go +++ b/internal/cmd/affinity-groups/delete/delete.go @@ -4,9 +4,13 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { @@ -26,7 +29,7 @@ const ( affinityGroupIdArg = "AFFINITY_GROUP" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", affinityGroupIdArg), Short: "Deletes an affinity group", @@ -40,35 +43,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - affinityGroupLabel, err := iaasUtils.GetAffinityGroupName(ctx, apiClient, model.ProjectId, model.AffinityGroupId) + affinityGroupLabel, err := iaasUtils.GetAffinityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.AffinityGroupId) if err != nil { - p.Debug(print.ErrorLevel, "get affinity group name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get affinity group name: %v", err) affinityGroupLabel = model.AffinityGroupId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete affinity group %q?", affinityGroupLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete affinity group %q?", affinityGroupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -77,7 +78,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("delete affinity group: %w", err) } - p.Info("Deleted affinity group %q for %q\n", affinityGroupLabel, projectLabel) + params.Printer.Info("Deleted affinity group %q for %q\n", affinityGroupLabel, projectLabel) return nil }, @@ -86,13 +87,13 @@ func NewCmd(p *print.Printer) *cobra.Command { } func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteAffinityGroupRequest { - return apiClient.DeleteAffinityGroup(ctx, model.ProjectId, model.AffinityGroupId) + return apiClient.DeleteAffinityGroup(ctx, model.ProjectId, model.Region, model.AffinityGroupId) } func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ @@ -100,14 +101,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM AffinityGroupId: cliArgs[0], } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/affinity-groups/delete/delete_test.go b/internal/cmd/affinity-groups/delete/delete_test.go index 71f2b84a7..00cb16756 100644 --- a/internal/cmd/affinity-groups/delete/delete_test.go +++ b/internal/cmd/affinity-groups/delete/delete_test.go @@ -4,15 +4,20 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -const projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,7 +41,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -49,6 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, AffinityGroupId: testAffinityGroupId, } @@ -59,7 +66,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteAffinityGroupRequest)) iaas.ApiDeleteAffinityGroupRequest { - request := testClient.DeleteAffinityGroup(testCtx, testProjectId, testAffinityGroupId) + request := testClient.DeleteAffinityGroup(testCtx, testProjectId, testRegion, testAffinityGroupId) for _, mod := range mods { mod(&request) } @@ -97,7 +104,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/affinity-groups/describe/describe.go b/internal/cmd/affinity-groups/describe/describe.go index 410937fb1..7fda51352 100644 --- a/internal/cmd/affinity-groups/describe/describe.go +++ b/internal/cmd/affinity-groups/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { @@ -27,7 +28,7 @@ const ( affinityGroupId = "AFFINITY_GROUP_ID" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", affinityGroupId), Short: "Show details of an affinity group", @@ -41,13 +42,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -59,7 +60,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get affinity group: %w", err) } - if err := outputResult(p, *model, *result); err != nil { + if err := outputResult(params.Printer, *model, *result); err != nil { return err } return nil @@ -69,7 +70,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiGetAffinityGroupRequest { - return apiClient.GetAffinityGroup(ctx, model.ProjectId, model.AffinityGroupId) + return apiClient.GetAffinityGroup(ctx, model.ProjectId, model.Region, model.AffinityGroupId) } func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { @@ -83,37 +84,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM AffinityGroupId: cliArgs[0], } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func outputResult(p *print.Printer, model inputModel, resp iaas.AffinityGroup) error { var outputFormat string if model.GlobalFlagModel != nil { - outputFormat = model.GlobalFlagModel.OutputFormat + outputFormat = model.OutputFormat } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal affinity group: %w", err) - } - p.Outputln(string(details)) - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal affinity group: %w", err) - } - p.Outputln(string(details)) - default: + + return p.OutputResult(outputFormat, resp, func() error { table := tables.NewTable() if resp.HasId() { @@ -136,6 +117,6 @@ func outputResult(p *print.Printer, model inputModel, resp iaas.AffinityGroup) e if err := table.Display(p); err != nil { return fmt.Errorf("render table: %w", err) } - } - return nil + return nil + }) } diff --git a/internal/cmd/affinity-groups/describe/describe_test.go b/internal/cmd/affinity-groups/describe/describe_test.go index 1d8a1f23b..966df5964 100644 --- a/internal/cmd/affinity-groups/describe/describe_test.go +++ b/internal/cmd/affinity-groups/describe/describe_test.go @@ -4,20 +4,25 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -const projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} var ( - testCtx = context.WithValue(context.Background(), &testCtxKey{}, projectIdFlag) + testCtx = context.WithValue(context.Background(), &testCtxKey{}, "test") testClient = &iaas.APIClient{} testProjectId = uuid.NewString() @@ -36,7 +41,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -49,6 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, AffinityGroupId: testAffinityGroupId, } @@ -59,7 +66,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetAffinityGroupRequest)) iaas.ApiGetAffinityGroupRequest { - request := testClient.GetAffinityGroup(testCtx, testProjectId, testAffinityGroupId) + request := testClient.GetAffinityGroup(testCtx, testProjectId, testRegion, testAffinityGroupId) for _, mod := range mods { mod(&request) } @@ -98,7 +105,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -191,7 +198,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { diff --git a/internal/cmd/affinity-groups/list/list.go b/internal/cmd/affinity-groups/list/list.go index 3c270bcdb..fe9abad60 100644 --- a/internal/cmd/affinity-groups/list/list.go +++ b/internal/cmd/affinity-groups/list/list.go @@ -2,14 +2,16 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { @@ -27,7 +28,7 @@ type inputModel struct { const limitFlag = "limit" -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists affinity groups", @@ -43,15 +44,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit affinity-group list --limit=10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,10 +68,10 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(*items) > int(*model.Limit) { *items = (*items)[:*model.Limit] } - return outputResult(p, *model, *items) + return outputResult(params.Printer, *model, *items) } - p.Outputln("No affinity groups found") + params.Printer.Outputln("No affinity groups found") return nil }, } @@ -83,10 +84,10 @@ func configureFlags(cmd *cobra.Command) { } func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiListAffinityGroupsRequest { - return apiClient.ListAffinityGroups(ctx, model.ProjectId) + return apiClient.ListAffinityGroups(ctx, model.ProjectId, model.Region) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -105,37 +106,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func outputResult(p *print.Printer, model inputModel, items []iaas.AffinityGroup) error { var outputFormat string if model.GlobalFlagModel != nil { - outputFormat = model.GlobalFlagModel.OutputFormat + outputFormat = model.OutputFormat } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(items, "", " ") - if err != nil { - return fmt.Errorf("marshal affinity groups: %w", err) - } - p.Outputln(string(details)) - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal affinity groups: %w", err) - } - p.Outputln(string(details)) - default: + + return p.OutputResult(outputFormat, items, func() error { table := tables.NewTable() table.SetHeader("ID", "NAME", "POLICY") for _, item := range items { @@ -150,6 +131,7 @@ func outputResult(p *print.Printer, model inputModel, items []iaas.AffinityGroup if err := table.Display(p); err != nil { return fmt.Errorf("render table: %w", err) } - } - return nil + + return nil + }) } diff --git a/internal/cmd/affinity-groups/list/list_test.go b/internal/cmd/affinity-groups/list/list_test.go index da35b5778..c872f4b45 100644 --- a/internal/cmd/affinity-groups/list/list_test.go +++ b/internal/cmd/affinity-groups/list/list_test.go @@ -5,16 +5,23 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -const projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" + testLimit = 10 +) type testCtxKey struct{} @@ -24,13 +31,10 @@ var ( testProjectId = uuid.NewString() ) -const ( - testLimit = 10 -) - func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -43,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, } for _, mod := range mods { @@ -52,7 +57,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListAffinityGroupsRequest)) iaas.ApiListAffinityGroupsRequest { - request := testClient.ListAffinityGroups(testCtx, testProjectId) + request := testClient.ListAffinityGroups(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -62,6 +67,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListAffinityGroupsRequest)) ia func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -104,43 +110,7 @@ func TestParseInput(t *testing.T) { } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Fatalf("configure global flags: %v", err) - } - - for flag, value := range tt.flagValues { - if err := cmd.Flags().Set(flag, value); err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", flag, value, err) - } - } - - if err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -186,7 +156,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { err := outputResult(p, tt.model, tt.response) diff --git a/internal/cmd/auth/activate-service-account/activate_service_account.go b/internal/cmd/auth/activate-service-account/activate_service_account.go index 46922720a..b69de90d7 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account.go @@ -4,7 +4,10 @@ import ( "errors" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -32,7 +35,7 @@ type inputModel struct { OnlyPrintAccessToken bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "activate-service-account", Short: "Authenticates using a service account", @@ -57,8 +60,11 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { - model := parseInput(p, cmd) + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey) if !model.OnlyPrintAccessToken { @@ -78,12 +84,12 @@ func NewCmd(p *print.Printer) *cobra.Command { // Initializes the authentication flow rt, err := sdkAuth.SetupAuth(cfg) if err != nil { - p.Debug(print.ErrorLevel, "setup auth: %v", err) + params.Printer.Debug(print.ErrorLevel, "setup auth: %v", err) return &cliErr.ActivateServiceAccountError{} } // Authenticates the service account and stores credentials - email, accessToken, err := auth.AuthenticateServiceAccount(p, rt, model.OnlyPrintAccessToken) + email, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, model.OnlyPrintAccessToken) if err != nil { var activateServiceAccountError *cliErr.ActivateServiceAccountError if !errors.As(err, &activateServiceAccountError) { @@ -94,9 +100,9 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.OnlyPrintAccessToken { // Only output is the access token - p.Outputf("%s\n", accessToken) + params.Printer.Outputf("%s\n", accessToken) } else { - p.Outputf("You have been successfully authenticated to the STACKIT CLI!\nService account email: %s\n", email) + params.Printer.Outputf("You have been successfully authenticated to the STACKIT CLI!\nService account email: %s\n", email) } return nil }, @@ -112,7 +118,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(onlyPrintAccessTokenFlag, false, "If this is set to true the credentials are not stored in either the keyring or a file") } -func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { model := inputModel{ ServiceAccountToken: flags.FlagToStringValue(p, cmd, serviceAccountTokenFlag), ServiceAccountKeyPath: flags.FlagToStringValue(p, cmd, serviceAccountKeyPathFlag), @@ -120,16 +126,8 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { OnlyPrintAccessToken: flags.FlagToBoolValue(p, cmd, onlyPrintAccessTokenFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - - return &model + p.DebugInputModel(model) + return &model, nil } func storeCustomEndpoint(tokenCustomEndpoint string) error { 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 index 9dcbb22b7..22a777ac4 100644 --- a/internal/cmd/auth/activate-service-account/activate_service_account_test.go +++ b/internal/cmd/auth/activate-service-account/activate_service_account_test.go @@ -3,14 +3,13 @@ package activateserviceaccount import ( "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/spf13/viper" - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" - "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/zalando/go-keyring" - "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) var testTokenCustomEndpoint = "token_url" @@ -44,6 +43,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string tokenCustomEndpoint string isValid bool @@ -105,32 +105,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go index 2a8b3a7f2..d54f3fb01 100644 --- a/internal/cmd/auth/auth.go +++ b/internal/cmd/auth/auth.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/auth/login" "github.com/stackitcloud/stackit-cli/internal/cmd/auth/logout" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "Authenticates the STACKIT CLI", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(login.NewCmd(p)) - cmd.AddCommand(logout.NewCmd(p)) - cmd.AddCommand(activateserviceaccount.NewCmd(p)) - cmd.AddCommand(getaccesstoken.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(login.NewCmd(params)) + cmd.AddCommand(logout.NewCmd(params)) + cmd.AddCommand(activateserviceaccount.NewCmd(params)) + cmd.AddCommand(getaccesstoken.NewCmd(params)) } diff --git a/internal/cmd/auth/get-access-token/get_access_token.go b/internal/cmd/auth/get-access-token/get_access_token.go index 85fc9247c..bf97df4ec 100644 --- a/internal/cmd/auth/get-access-token/get_access_token.go +++ b/internal/cmd/auth/get-access-token/get_access_token.go @@ -1,15 +1,26 @@ package getaccesstoken import ( + "encoding/json" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) -func NewCmd(p *print.Printer) *cobra.Command { +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "get-access-token", Short: "Prints a short-lived access token.", @@ -20,7 +31,12 @@ func NewCmd(p *print.Printer) *cobra.Command { `Print a short-lived access token`, "$ stackit auth get-access-token"), ), - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + userSessionExpired, err := auth.UserSessionExpired() if err != nil { return err @@ -29,22 +45,47 @@ func NewCmd(p *print.Printer) *cobra.Command { return &cliErr.SessionExpiredError{} } - accessToken, err := auth.GetAccessToken() + accessToken, err := auth.GetValidAccessToken(params.Printer) if err != nil { - return err + params.Printer.Debug(print.ErrorLevel, "get valid access token: %v", err) + return &cliErr.SessionExpiredError{} } - accessTokenExpired, err := auth.TokenExpired(accessToken) - if err != nil { - return err - } - if accessTokenExpired { - return &cliErr.AccessTokenExpiredError{} - } + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(map[string]string{ + "access_token": accessToken, + }, "", " ") + if err != nil { + return fmt.Errorf("marshal image list: %w", err) + } + params.Printer.Outputln(string(details)) - p.Outputf("%s\n", accessToken) - return nil + return nil + default: + params.Printer.Outputln(accessToken) + + return nil + } }, } + + // hide project id flag from help command because it could mislead users + cmd.SetHelpFunc(func(command *cobra.Command, strings []string) { + _ = command.Flags().MarkHidden(globalflags.ProjectIdFlag) // nolint:errcheck // there's no chance to handle the error here + command.Parent().HelpFunc()(command, strings) + }) + return cmd } + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go index 06531f706..8a03d19af 100644 --- a/internal/cmd/auth/login/login.go +++ b/internal/cmd/auth/login/login.go @@ -6,12 +6,22 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +const ( + portFlag = "port" +) + +type inputModel struct { + Port *int +} + +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Logs in to the STACKIT CLI", @@ -24,16 +34,47 @@ func NewCmd(p *print.Printer) *cobra.Command { `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(_ *cobra.Command, _ []string) error { - err := auth.AuthorizeUser(p, false) + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + err = auth.AuthorizeUser(params.Printer, auth.UserAuthConfig{ + IsReauthentication: false, + Port: model.Port, + }) if err != nil { return fmt.Errorf("authorization failed: %w", err) } - p.Outputln("Successfully logged into STACKIT CLI.\n") + params.Printer.Outputln("Successfully logged into STACKIT CLI.\n") return nil }, } + configureFlags(cmd) return cmd } + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int(portFlag, 0, + "The port on which the callback server will listen to. By default, it tries to bind a port between 8000 and 8020.\n"+ + "When a value is specified, it will only try to use the specified port. Valid values are within the range of 8000 to 8020.", + ) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + port := flags.FlagToIntPointer(p, cmd, portFlag) + // For the CLI client only callback URLs with localhost:[8000-8020] are valid. Additional callbacks must be enabled in the backend. + if port != nil && (*port < 8000 || 8020 < *port) { + return nil, fmt.Errorf("port must be between 8000 and 8020") + } + + model := inputModel{ + Port: port, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/auth/login/login_test.go b/internal/cmd/auth/login/login_test.go new file mode 100644 index 000000000..823fa863e --- /dev/null +++ b/internal/cmd/auth/login/login_test.go @@ -0,0 +1,93 @@ +package login + +import ( + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + portFlag: "8010", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + Port: utils.Ptr(8010), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: &inputModel{ + Port: nil, + }, + }, + { + description: "lower limit", + flagValues: map[string]string{ + portFlag: "8000", + }, + isValid: true, + expectedModel: &inputModel{ + Port: utils.Ptr(8000), + }, + }, + { + description: "below lower limit is not valid ", + flagValues: map[string]string{ + portFlag: "7999", + }, + isValid: false, + }, + { + description: "upper limit", + flagValues: map[string]string{ + portFlag: "8020", + }, + isValid: true, + expectedModel: &inputModel{ + Port: utils.Ptr(8020), + }, + }, + { + description: "above upper limit is not valid ", + flagValues: map[string]string{ + portFlag: "8021", + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} diff --git a/internal/cmd/auth/logout/logout.go b/internal/cmd/auth/logout/logout.go index adc6f7069..e5e4f6be8 100644 --- a/internal/cmd/auth/logout/logout.go +++ b/internal/cmd/auth/logout/logout.go @@ -3,15 +3,16 @@ package logout import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "logout", Short: "Logs the user account out of the STACKIT CLI", @@ -28,7 +29,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("log out failed: %w", err) } - p.Info("Successfully logged out of the STACKIT CLI.\n") + params.Printer.Info("Successfully logged out of the STACKIT CLI.\n") return nil }, } diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go index 94f082da8..62bd90d2b 100644 --- a/internal/cmd/beta/alb/alb.go +++ b/internal/cmd/beta/alb/alb.go @@ -12,14 +12,14 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/template" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "alb", Short: "Manages application loadbalancers", @@ -27,21 +27,21 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand( - list.NewCmd(p), - template.NewCmd(p), - create.NewCmd(p), - update.NewCmd(p), - observabilitycredentials.NewCmd(p), - describe.NewCmd(p), - delete.NewCmd(p), - pool.NewCmd(p), - plans.NewCmd(p), - quotas.NewCmd(p), + list.NewCmd(params), + template.NewCmd(params), + create.NewCmd(params), + update.NewCmd(params), + observabilitycredentials.NewCmd(params), + describe.NewCmd(params), + delete.NewCmd(params), + pool.NewCmd(params), + plans.NewCmd(params), + quotas.NewCmd(params), ) } diff --git a/internal/cmd/beta/alb/create/create.go b/internal/cmd/beta/alb/create/create.go index 462d4e381..5e7989f4e 100644 --- a/internal/cmd/beta/alb/create/create.go +++ b/internal/cmd/beta/alb/create/create.go @@ -8,7 +8,10 @@ import ( "os" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -34,7 +37,7 @@ type inputModel struct { Configuration *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates an application loadbalancer", @@ -45,31 +48,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create an application loadbalancer from a configuration file`, "$ stackit beta alb create --configuration my-loadbalancer.json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create an application loadbalancer for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create an application loadbalancer for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,16 +85,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating loadbalancer") - _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating loadbalancer", func() error { + _, err := wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for loadbalancer creation: %w", err) } - s.Stop() } - return outputResult(p, model, projectLabel, resp) + return outputResult(params.Printer, model, projectLabel, resp) }, } configureFlags(cmd) @@ -106,7 +107,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -117,15 +118,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -169,29 +162,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp if resp == nil { return fmt.Errorf("create loadbalancer response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal loadbalancer: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal loadbalancer: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Created" if model.Async { operationState = "Triggered creation of" } p.Outputf("%s application loadbalancer for %q. Name: %s\n", operationState, projectLabel, utils.PtrString(resp.Name)) return nil - } + }) } diff --git a/internal/cmd/beta/alb/create/create_test.go b/internal/cmd/beta/alb/create/create_test.go index d77fb2022..6641a2fc2 100644 --- a/internal/cmd/beta/alb/create/create_test.go +++ b/internal/cmd/beta/alb/create/create_test.go @@ -7,13 +7,17 @@ import ( "log" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/alb" ) //go:embed testdata/testconfig.json @@ -78,6 +82,7 @@ func fixtureRequest(mods ...func(request *alb.ApiCreateLoadBalancerRequest)) alb func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -133,46 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -248,7 +214,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/alb/delete/delete.go b/internal/cmd/beta/alb/delete/delete.go index c3709e8a9..94b8163ee 100644 --- a/internal/cmd/beta/alb/delete/delete.go +++ b/internal/cmd/beta/alb/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -24,7 +26,7 @@ type inputModel struct { Name string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", loadbalancerNameArg), Short: "Deletes an application loadbalancer", @@ -38,29 +40,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete the application loadbalancer %q for project %q?", model.Name, projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete the application loadbalancer %q for project %q?", model.Name, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -70,7 +70,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete loadbalancer: %w", err) } - p.Outputf("Load balancer %q deleted.", model.Name) + params.Printer.Outputf("Load balancer %q deleted.", model.Name) return nil }, } @@ -86,15 +86,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Name: loadbalancerName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/beta/alb/delete/delete_test.go b/internal/cmd/beta/alb/delete/delete_test.go index 3bc65bfd1..6c5290a52 100644 --- a/internal/cmd/beta/alb/delete/delete_test.go +++ b/internal/cmd/beta/alb/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -111,54 +111,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.argsValues) - 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(p, cmd, tt.argsValues) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/beta/alb/describe/describe.go b/internal/cmd/beta/alb/describe/describe.go index 8a1403585..6d4cae785 100644 --- a/internal/cmd/beta/alb/describe/describe.go +++ b/internal/cmd/beta/alb/describe/describe.go @@ -2,10 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -15,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/alb" ) @@ -29,7 +29,7 @@ type inputModel struct { Name string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", loadbalancerNameArg), Short: "Describes an application loadbalancer", @@ -43,13 +43,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -62,9 +62,9 @@ func NewCmd(p *print.Printer) *cobra.Command { } if loadbalancer := resp; loadbalancer != nil { - return outputResult(p, model.OutputFormat, loadbalancer) + return outputResult(params.Printer, model.OutputFormat, loadbalancer) } - p.Outputln("No load balancer found.") + params.Printer.Outputln("No load balancer found.") return nil }, } @@ -80,15 +80,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Name: loadbalancerName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -96,54 +88,27 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie return apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, model.Name) } -func outputResult(p *print.Printer, outputFormat string, response *alb.LoadBalancer) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(response, "", " ") +func outputResult(p *print.Printer, outputFormat string, loadbalancer *alb.LoadBalancer) error { + return p.OutputResult(outputFormat, loadbalancer, func() error { + content := []tables.Table{} - if err != nil { - return fmt.Errorf("marshal loadbalancer: %w", err) + content = append(content, buildLoadBalancerTable(loadbalancer)) + + if loadbalancer.Listeners != nil { + content = append(content, buildListenersTable(*loadbalancer.Listeners)) } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(response, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if loadbalancer.TargetPools != nil { + content = append(content, buildTargetPoolsTable(*loadbalancer.TargetPools)) + } + err := tables.DisplayTables(p, content) if err != nil { - return fmt.Errorf("marshal loadbalancer: %w", err) + return fmt.Errorf("display output: %w", err) } - p.Outputln(string(details)) return nil - default: - if err := outputResultAsTable(p, response); err != nil { - return err - } - } - - return nil -} - -func outputResultAsTable(p *print.Printer, loadbalancer *alb.LoadBalancer) error { - content := []tables.Table{} - - content = append(content, buildLoadBalancerTable(loadbalancer)) - - if loadbalancer.Listeners != nil { - content = append(content, buildListenersTable(*loadbalancer.Listeners)) - } - - if loadbalancer.TargetPools != nil { - content = append(content, buildTargetPoolsTable(*loadbalancer.TargetPools)) - } - - err := tables.DisplayTables(p, content) - if err != nil { - return fmt.Errorf("display output: %w", err) - } - - return nil + }) } func buildLoadBalancerTable(loadbalancer *alb.LoadBalancer) tables.Table { diff --git a/internal/cmd/beta/alb/describe/describe_test.go b/internal/cmd/beta/alb/describe/describe_test.go index 0f621c72e..9d79acbad 100644 --- a/internal/cmd/beta/alb/describe/describe_test.go +++ b/internal/cmd/beta/alb/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -111,54 +114,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.argsValues) - 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(p, cmd, tt.argsValues) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) }) } } @@ -213,7 +169,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.response); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/alb/list/list.go b/internal/cmd/beta/alb/list/list.go index 4050e8791..1967f706e 100644 --- a/internal/cmd/beta/alb/list/list.go +++ b/internal/cmd/beta/alb/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/alb" ) type inputModel struct { @@ -26,11 +27,10 @@ type inputModel struct { } const ( - labelSelectorFlag = "label-selector" - limitFlag = "limit" + limitFlag = "limit" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists albs", @@ -46,22 +46,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta alb list --limit=10`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId @@ -74,19 +74,14 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list load balancerse: %w", err) } + items := response.GetLoadBalancers() - if items := response.LoadBalancers; items == nil || len(*items) == 0 { - p.Info("No load balancers found for project %q", projectLabel) - } else { - if model.Limit != nil && len(*items) > int(*model.Limit) { - *items = (*items)[:*model.Limit] - } - if err := outputResult(p, model.OutputFormat, *items); err != nil { - return fmt.Errorf("output loadbalancers: %w", err) - } + // Truncate output + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] } - return nil + return outputResult(params.Printer, model.OutputFormat, projectLabel, items) }, } @@ -98,7 +93,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -117,15 +112,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -134,28 +121,18 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie return request } -func outputResult(p *print.Printer, outputFormat string, items []alb.LoadBalancer) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(items, "", " ") - if err != nil { - return fmt.Errorf("marshal loadbalancer list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal loadbalancer list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, items []alb.LoadBalancer) error { + return p.OutputResult(outputFormat, items, func() error { + if len(items) == 0 { + p.Outputf("No load balancers found for project %q", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("NAME", "EXTERNAL ADDRESS", "REGION", "STATUS", "VERSION", "ERRORS") - for _, item := range items { + for i := range items { + item := &items[i] + var errNo int if item.Errors != nil { errNo = len(*item.Errors) @@ -174,5 +151,5 @@ func outputResult(p *print.Printer, outputFormat string, items []alb.LoadBalance } return nil - } + }) } diff --git a/internal/cmd/beta/alb/list/list_test.go b/internal/cmd/beta/alb/list/list_test.go index 77c915266..b0524705a 100644 --- a/internal/cmd/beta/alb/list/list_test.go +++ b/internal/cmd/beta/alb/list/list_test.go @@ -5,11 +5,17 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/alb" ) @@ -17,11 +23,14 @@ import ( type testCtxKey struct{} var ( - testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") - testClient = &alb.APIClient{} - testProjectId = uuid.NewString() - testRegion = "eu01" - testLimit int64 = 10 + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &alb.APIClient{} + testProjectId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testLimit int64 = 10 ) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { @@ -39,7 +48,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Region: testRegion, Verbosity: globalflags.VerbosityDefault}, - Limit: &testLimit, + Limit: utils.Ptr(testLimit), } for _, mod := range mods { mod(model) @@ -48,7 +57,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *alb.ApiListLoadBalancersRequest)) alb.ApiListLoadBalancersRequest { - request := testClient.ListLoadBalancers(context.Background(), testProjectId, testRegion) + request := testClient.ListLoadBalancers(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -58,6 +67,7 @@ func fixtureRequest(mods ...func(request *alb.ApiListLoadBalancersRequest)) alb. func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -98,44 +108,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -159,7 +132,6 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), - cmpopts.IgnoreFields(alb.ApiListLoadBalancersRequest{}, "ctx"), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -171,6 +143,7 @@ func TestBuildRequest(t *testing.T) { func Test_outputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string items []alb.LoadBalancer } tests := []struct { @@ -196,10 +169,10 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.items); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.items); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/beta/alb/observability-credentials/add/add.go b/internal/cmd/beta/alb/observability-credentials/add/add.go index 806af5e13..69d21973b 100644 --- a/internal/cmd/beta/alb/observability-credentials/add/add.go +++ b/internal/cmd/beta/alb/observability-credentials/add/add.go @@ -2,9 +2,10 @@ package add import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -12,7 +13,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/alb" ) @@ -30,7 +30,7 @@ type inputModel struct { Password *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "Adds observability credentials to an application load balancer", @@ -41,26 +41,24 @@ func NewCmd(p *print.Printer) *cobra.Command { `Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag`, "$ stackit beta alb observability-credentials add --username xxx --password @./password.txt --display-name yyy"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := "Are your sure you want to add credentials?" - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := "Are your sure you want to add credentials?" + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -70,7 +68,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("add credential: %w", err) } - return outputResult(p, model.GlobalFlagModel.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -85,7 +83,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(flags.MarkFlagsRequired(cmd, usernameFlag, displaynameFlag)) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) model := inputModel{ @@ -95,15 +93,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Password: flags.FlagToStringPointer(p, cmd, passwordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string fo debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -122,25 +112,10 @@ func outputResult(p *print.Printer, outputFormat string, item *alb.CreateCredent return fmt.Errorf("no credential found") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(item, "", " ") - if err != nil { - return fmt.Errorf("marshal credential: %w", err) - } - p.Outputln(string(details)) - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(item, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal credential: %w", err) - } - p.Outputln(string(details)) - default: + return p.OutputResult(outputFormat, item, func() error { if item.Credential != nil { - p.Outputf("Created credential %s\n", - utils.PtrString(item.Credential.CredentialsRef), - ) + p.Outputf("Created credential %s\n", utils.PtrString(item.Credential.CredentialsRef)) } - } - return nil + return nil + }) } diff --git a/internal/cmd/beta/alb/observability-credentials/add/add_test.go b/internal/cmd/beta/alb/observability-credentials/add/add_test.go index a84b1405b..de16544a6 100644 --- a/internal/cmd/beta/alb/observability-credentials/add/add_test.go +++ b/internal/cmd/beta/alb/observability-credentials/add/add_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -81,6 +84,7 @@ func fixturePayload(mods ...func(payload *alb.CreateCredentialsPayload)) alb.Cre func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -100,46 +104,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -202,7 +167,7 @@ func Test_outputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.item); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/alb/observability-credentials/delete/delete.go b/internal/cmd/beta/alb/observability-credentials/delete/delete.go index e71a6fee4..274f977f2 100644 --- a/internal/cmd/beta/alb/observability-credentials/delete/delete.go +++ b/internal/cmd/beta/alb/observability-credentials/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -23,7 +25,7 @@ type inputModel struct { CredentialsRef string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialRefArg), Short: "Deletes credentials", @@ -37,23 +39,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials %q?", model.CredentialsRef) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials %q?", model.CredentialsRef) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete credential: %w", err) } - p.Info("Deleted credential %q\n", model.CredentialsRef) + params.Printer.Info("Deleted credential %q\n", model.CredentialsRef) return nil }, @@ -81,15 +81,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsRef: credentialRef, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go b/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go index 6f5ff0dd8..20d9e9b51 100644 --- a/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go +++ b/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go @@ -4,9 +4,12 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/alb" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -112,7 +115,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/beta/alb/observability-credentials/describe/describe.go b/internal/cmd/beta/alb/observability-credentials/describe/describe.go index 091d08ef2..fe370a10d 100644 --- a/internal/cmd/beta/alb/observability-credentials/describe/describe.go +++ b/internal/cmd/beta/alb/observability-credentials/describe/describe.go @@ -2,9 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -14,7 +15,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/alb" ) @@ -28,7 +28,7 @@ type inputModel struct { CredentialRef string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", credentialRefArg), Short: "Describes observability credentials for the Application Load Balancer", @@ -42,13 +42,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -61,9 +61,9 @@ func NewCmd(p *print.Printer) *cobra.Command { } if credential := resp; credential != nil && credential.Credential != nil { - return outputResult(p, model.OutputFormat, *credential.Credential) + return outputResult(params.Printer, model.OutputFormat, *credential.Credential) } - p.Outputln("No credentials found.") + params.Printer.Outputln("No credentials found.") return nil }, } @@ -79,15 +79,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialRef: credentialRef, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -96,26 +88,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie } func outputResult(p *print.Printer, outputFormat string, response alb.CredentialsResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(response, "", " ") - - if err != nil { - return fmt.Errorf("marshal credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(response, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - - if err != nil { - return fmt.Errorf("marshal credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, response, func() error { table := tables.NewTable() table.AddRow("CREDENTIAL REF", utils.PtrString(response.CredentialsRef)) table.AddSeparator() @@ -127,7 +100,6 @@ func outputResult(p *print.Printer, outputFormat string, response alb.Credential table.AddSeparator() p.Outputln(table.Render()) - } - - return nil + return nil + }) } diff --git a/internal/cmd/beta/alb/observability-credentials/describe/describe_test.go b/internal/cmd/beta/alb/observability-credentials/describe/describe_test.go index 1d4f201a5..7846a6b21 100644 --- a/internal/cmd/beta/alb/observability-credentials/describe/describe_test.go +++ b/internal/cmd/beta/alb/observability-credentials/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -111,54 +114,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.argsValues) - 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(p, cmd, tt.argsValues) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) }) } } @@ -213,7 +169,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.response); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/alb/observability-credentials/list/list.go b/internal/cmd/beta/alb/observability-credentials/list/list.go index d08a9026e..5c44aae92 100644 --- a/internal/cmd/beta/alb/observability-credentials/list/list.go +++ b/internal/cmd/beta/alb/observability-credentials/list/list.go @@ -2,9 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -16,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/alb" ) @@ -30,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all credentials", @@ -50,15 +50,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit beta alb observability-credentials list --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -69,18 +69,14 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list credentials: %w", err) } + items := resp.GetCredentials() - if resp.Credentials == nil || len(*resp.Credentials) == 0 { - p.Info("No credentials found\n") - return nil - } - - items := *resp.Credentials + // Truncate output if model.Limit != nil && len(items) > int(*model.Limit) { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -91,7 +87,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Number of credentials to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) @@ -107,15 +103,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.InfoLevel, modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -125,26 +113,12 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie } func outputResult(p *print.Printer, outputFormat string, items []alb.CredentialsResponse) error { - if items == nil { - p.Outputln("no credentials found") - return nil - } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(items, "", " ") - if err != nil { - return fmt.Errorf("marshal credentials: %w", err) - } - p.Outputln(string(details)) - - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal credentials: %w", err) + return p.OutputResult(outputFormat, items, func() error { + if len(items) == 0 { + p.Outputf("No credentials found\n") + return nil } - p.Outputln(string(details)) - default: table := tables.NewTable() table.SetHeader("CREDENTIAL REF", "DISPLAYNAME", "USERNAME", "REGION") @@ -158,6 +132,6 @@ func outputResult(p *print.Printer, outputFormat string, items []alb.Credentials } p.Outputln(table.Render()) - } - return nil + return nil + }) } diff --git a/internal/cmd/beta/alb/observability-credentials/list/list_test.go b/internal/cmd/beta/alb/observability-credentials/list/list_test.go index f3c305298..eacc42dea 100644 --- a/internal/cmd/beta/alb/observability-credentials/list/list_test.go +++ b/internal/cmd/beta/alb/observability-credentials/list/list_test.go @@ -5,8 +5,11 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -64,6 +67,7 @@ func fixtureRequest(mods ...func(request *alb.ApiListCredentialsRequest)) alb.Ap func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -119,46 +123,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error parsing input: %v", err) - } - - if !tt.isValid { - t.Fatal("did not fail on invalid input") - } - diff := cmp.Diff(model, tt.expectedModel) - if diff != "" { - t.Fatalf("data does not match: %s", diff) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -216,7 +181,7 @@ func Test_outputResult(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) if err := outputResult(p, tt.args.outputFormat, tt.args.response); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) diff --git a/internal/cmd/beta/alb/observability-credentials/observability-credentials.go b/internal/cmd/beta/alb/observability-credentials/observability-credentials.go index 04dee2407..2c0b7d461 100644 --- a/internal/cmd/beta/alb/observability-credentials/observability-credentials.go +++ b/internal/cmd/beta/alb/observability-credentials/observability-credentials.go @@ -3,16 +3,17 @@ package credentials import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + add "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/add" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/list" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials/update" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "observability-credentials", Short: "Provides functionality for application loadbalancer credentials", @@ -20,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: cobra.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(add.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(add.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/beta/alb/observability-credentials/update/update.go b/internal/cmd/beta/alb/observability-credentials/update/update.go index 03a9fe8ad..18cbf8eb2 100644 --- a/internal/cmd/beta/alb/observability-credentials/update/update.go +++ b/internal/cmd/beta/alb/observability-credentials/update/update.go @@ -2,9 +2,10 @@ package update import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -14,7 +15,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/alb" ) @@ -34,7 +34,7 @@ type inputModel struct { CredentialsRef *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", credentialRefArg), Short: "Update credentials", @@ -47,10 +47,10 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model := parseInput(p, cmd, args) + model := parseInput(params.Printer, cmd, args) // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -59,17 +59,15 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update credential %q for %q?", *model.CredentialsRef, projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return fmt.Errorf("update credential: %w", err) - } + prompt := fmt.Sprintf("Are you sure you want to update credential %q for %q?", *model.CredentialsRef, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return fmt.Errorf("update credential: %w", err) } // Call API @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("response is nil") } - return outputResult(p, model, resp) + return outputResult(params.Printer, model, resp) }, } configureFlags(cmd) @@ -121,41 +119,21 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) inputM Password: flags.FlagToStringPointer(p, cmd, passwordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return model } func outputResult(p *print.Printer, model inputModel, response *alb.UpdateCredentialsResponse) error { var outputFormat string if model.GlobalFlagModel != nil { - outputFormat = model.GlobalFlagModel.OutputFormat + outputFormat = model.OutputFormat } if response == nil { - return fmt.Errorf("no response passewd") + return fmt.Errorf("no response passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(response.Credential, "", " ") - if err != nil { - return fmt.Errorf("marshal credential: %w", err) - } - p.Outputln(string(details)) - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(response.Credential, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal credential: %w", err) - } - p.Outputln(string(details)) - default: + + return p.OutputResult(outputFormat, response.Credential, func() error { p.Outputf("Updated credential %q\n", utils.PtrString(model.CredentialsRef)) - } - return nil + return nil + }) } diff --git a/internal/cmd/beta/alb/observability-credentials/update/update_test.go b/internal/cmd/beta/alb/observability-credentials/update/update_test.go index bdfab0080..1697fc13a 100644 --- a/internal/cmd/beta/alb/observability-credentials/update/update_test.go +++ b/internal/cmd/beta/alb/observability-credentials/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -83,6 +85,7 @@ func fixturePayload(mods ...func(payload *alb.UpdateCredentialsPayload)) alb.Upd func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string args []string isValid bool @@ -126,7 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -216,7 +219,7 @@ func Test_outputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.item); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/alb/plans/plans.go b/internal/cmd/beta/alb/plans/plans.go index 13719baac..bd672fd23 100644 --- a/internal/cmd/beta/alb/plans/plans.go +++ b/internal/cmd/beta/alb/plans/plans.go @@ -2,11 +2,13 @@ package plans import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,14 +18,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/alb" ) type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "plans", Short: "Lists the application load balancer plans", @@ -35,22 +36,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta alb plans`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId @@ -63,23 +64,16 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list plans: %w", err) } + items := response.GetValidPlans() - if items := response.ValidPlans; items == nil || len(*items) == 0 { - p.Info("No plans found for project %q", projectLabel) - } else { - if err := outputResult(p, model.OutputFormat, *items); err != nil { - return fmt.Errorf("output plans: %w", err) - } - } - - return nil + return outputResult(params.Printer, model.OutputFormat, projectLabel, items) }, } return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -89,15 +83,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -107,25 +93,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie return request } -func outputResult(p *print.Printer, outputFormat string, items []alb.PlanDetails) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(items, "", " ") - if err != nil { - return fmt.Errorf("marshal plans: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal plans: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, items []alb.PlanDetails) error { + return p.OutputResult(outputFormat, items, func() error { + if len(items) == 0 { + p.Outputf("No plans found for project %q", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("PLAN ID", "NAME", "FLAVOR", "MAX CONNS", "DESCRIPTION") for _, item := range items { @@ -142,5 +116,5 @@ func outputResult(p *print.Printer, outputFormat string, items []alb.PlanDetails } return nil - } + }) } diff --git a/internal/cmd/beta/alb/plans/plans_test.go b/internal/cmd/beta/alb/plans/plans_test.go index 3184f5696..c104680e5 100644 --- a/internal/cmd/beta/alb/plans/plans_test.go +++ b/internal/cmd/beta/alb/plans/plans_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -19,9 +22,10 @@ var ( testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") testClient = &alb.APIClient{} testProjectId = uuid.NewString() - testRegion = "eu01" ) +const testRegion = "eu01" + func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ globalflags.ProjectIdFlag: testProjectId, @@ -54,6 +58,7 @@ func fixtureRequest(mods ...func(request *alb.ApiListPlansRequest)) alb.ApiListP func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -94,44 +99,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -155,7 +123,6 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), - cmpopts.IgnoreFields(alb.ApiListLoadBalancersRequest{}, "ctx"), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -167,6 +134,7 @@ func TestBuildRequest(t *testing.T) { func Test_outputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string items []alb.PlanDetails } tests := []struct { @@ -192,10 +160,10 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.items); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.items); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/beta/alb/pool/pool.go b/internal/cmd/beta/alb/pool/pool.go index 5d8d5fd41..f83b7728b 100644 --- a/internal/cmd/beta/alb/pool/pool.go +++ b/internal/cmd/beta/alb/pool/pool.go @@ -3,14 +3,14 @@ package pool import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/pool/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "pool", Short: "Manages target pools for application loadbalancers", @@ -18,10 +18,10 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/beta/alb/pool/update/update.go b/internal/cmd/beta/alb/pool/update/update.go index e8b2aa2fa..c20aac785 100644 --- a/internal/cmd/beta/alb/pool/update/update.go +++ b/internal/cmd/beta/alb/pool/update/update.go @@ -8,7 +8,10 @@ import ( "os" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -34,7 +37,7 @@ type inputModel struct { AlbName *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "Updates an application target pool", @@ -45,31 +48,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Update an application target pool from a configuration file (the name of the pool is read from the file)`, "$ stackit beta alb update --configuration my-target-pool.json --name my-load-balancer"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update an application target pool for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update an application target pool for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -82,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update application target pool: %w", err) } - return outputResult(p, model, projectLabel, resp) + return outputResult(params.Printer, model, projectLabel, resp) }, } configureFlags(cmd) @@ -96,7 +97,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -108,15 +109,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { AlbName: flags.FlagToStringPointer(p, cmd, albNameFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -163,29 +156,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp if resp == nil { return fmt.Errorf("update target pool response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal target pool: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal target pool: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Updated" if model.Async { operationState = "Triggered update of" } p.Outputf("%s application target pool for %q. Name: %s\n", operationState, projectLabel, utils.PtrString(resp.Name)) return nil - } + }) } diff --git a/internal/cmd/beta/alb/pool/update/update_test.go b/internal/cmd/beta/alb/pool/update/update_test.go index b9b9ec080..09af0fc92 100644 --- a/internal/cmd/beta/alb/pool/update/update_test.go +++ b/internal/cmd/beta/alb/pool/update/update_test.go @@ -7,13 +7,17 @@ import ( "log" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/alb" ) //go:embed testdata/testconfig.json @@ -84,6 +88,7 @@ func fixtureRequest(mods ...func(request *alb.ApiUpdateTargetPoolRequest)) alb.A func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -141,46 +146,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -257,7 +223,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/alb/quotas/quotas.go b/internal/cmd/beta/alb/quotas/quotas.go index 07bc2eb21..3f77bbaa0 100644 --- a/internal/cmd/beta/alb/quotas/quotas.go +++ b/internal/cmd/beta/alb/quotas/quotas.go @@ -2,11 +2,13 @@ package quotas import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,14 +17,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/alb" ) type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "quotas", Short: "Shows the application load balancer quotas", @@ -34,15 +35,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta alb quotas`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -56,11 +57,11 @@ func NewCmd(p *print.Printer) *cobra.Command { } if response == nil { - p.Outputln("no quotas found") + params.Printer.Outputln("no quotas found") return nil } - if err := outputResult(p, model.OutputFormat, *response); err != nil { + if err := outputResult(params.Printer, model.OutputFormat, *response); err != nil { return fmt.Errorf("output loadbalancers: %w", err) } @@ -71,7 +72,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -81,15 +82,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -100,24 +93,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClie } func outputResult(p *print.Printer, outputFormat string, response alb.GetQuotaResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(response, "", " ") - if err != nil { - return fmt.Errorf("marshal quotas: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(response, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal quotas: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, response, func() error { table := tables.NewTable() table.AddRow("REGION", utils.PtrString(response.Region)) table.AddSeparator() @@ -128,5 +104,5 @@ func outputResult(p *print.Printer, outputFormat string, response alb.GetQuotaRe } return nil - } + }) } diff --git a/internal/cmd/beta/alb/quotas/quotas_test.go b/internal/cmd/beta/alb/quotas/quotas_test.go index b840d9ef0..80ee324f6 100644 --- a/internal/cmd/beta/alb/quotas/quotas_test.go +++ b/internal/cmd/beta/alb/quotas/quotas_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -54,6 +57,7 @@ func fixtureRequest(mods ...func(request *alb.ApiGetQuotaRequest)) alb.ApiGetQuo func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -94,44 +98,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -155,7 +122,6 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), - cmpopts.IgnoreFields(alb.ApiListLoadBalancersRequest{}, "ctx"), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -192,7 +158,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.response); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go index 18d0cd427..c91289a2c 100644 --- a/internal/cmd/beta/alb/template/template.go +++ b/internal/cmd/beta/alb/template/template.go @@ -6,8 +6,12 @@ import ( "fmt" "os" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/goccy/go-yaml" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/alb" ) const ( @@ -36,7 +39,7 @@ var ( templatePool string ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "template", Short: "creates configuration templates to use for resource creation", @@ -52,8 +55,8 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta alb template --format=json --type pool`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { - model, err := parseInput(p, cmd) + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -73,7 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } if model.Format == nil || *model.Format == "yaml" { - p.Outputln(template) + params.Printer.Outputln(template) } else if *model.Format == "json" { if err := yaml.Unmarshal([]byte(template), &target); err != nil { return fmt.Errorf("cannot unmarshal template: %w", err) @@ -99,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().VarP(flags.EnumFlag(true, "alb", "alb", "pool"), typeFlag, "t", "Defines the output type ('alb' or 'pool'), default is 'alb'") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -111,14 +114,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Type: flags.FlagToStringPointer(p, cmd, typeFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/beta/alb/template/template_test.go b/internal/cmd/beta/alb/template/template_test.go index 3ca5404e8..7f73d3f7d 100644 --- a/internal/cmd/beta/alb/template/template_test.go +++ b/internal/cmd/beta/alb/template/template_test.go @@ -4,10 +4,9 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/google/go-cmp/cmp" "github.com/google/uuid" ) @@ -40,6 +39,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -137,44 +137,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/beta/alb/update/update.go b/internal/cmd/beta/alb/update/update.go index 546f89990..006c027d4 100644 --- a/internal/cmd/beta/alb/update/update.go +++ b/internal/cmd/beta/alb/update/update.go @@ -8,7 +8,10 @@ import ( "os" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -35,7 +38,7 @@ type inputModel struct { Version *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "Updates an application loadbalancer", @@ -46,31 +49,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Update an application loadbalancer from a configuration file`, "$ stackit beta alb update --configuration my-loadbalancer.json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update an application loadbalancer for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update an application loadbalancer for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // for updates of an existing ALB the current version must be passed to the request @@ -90,17 +91,17 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("updating loadbalancer") - _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name). - WaitWithContext(ctx) + err := spinner.Run(params.Printer, "updating loadbalancer", func() error { + _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name). + WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for loadbalancer update: %w", err) } - s.Stop() } - return outputResult(p, model, projectLabel, resp) + return outputResult(params.Printer, model, projectLabel, resp) }, } configureFlags(cmd) @@ -113,7 +114,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -124,15 +125,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -199,29 +192,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp if resp == nil { return fmt.Errorf("update loadbalancer response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal loadbalancer: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal loadbalancer: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Updated" if model.Async { operationState = "Triggered update of" } p.Outputf("%s application loadbalancer for %q. Name: %s\n", operationState, projectLabel, utils.PtrString(resp.Name)) return nil - } + }) } diff --git a/internal/cmd/beta/alb/update/update_test.go b/internal/cmd/beta/alb/update/update_test.go index 176a130a3..84220cfdc 100644 --- a/internal/cmd/beta/alb/update/update_test.go +++ b/internal/cmd/beta/alb/update/update_test.go @@ -7,13 +7,17 @@ import ( "log" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/alb" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/alb" ) //go:embed testdata/testconfig.json @@ -81,6 +85,7 @@ func fixtureRequest(mods ...func(request *alb.ApiUpdateLoadBalancerRequest)) alb func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -136,46 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -251,7 +217,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 37b5e4bd9..1bcb3ae55 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -3,17 +3,22 @@ package beta import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "beta", Short: "Contains beta STACKIT CLI commands", @@ -31,11 +36,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit beta MY_COMMAND"), ), } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(sqlserverflex.NewCmd(p)) - cmd.AddCommand(alb.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(sqlserverflex.NewCmd(params)) + cmd.AddCommand(sfs.NewCmd(params)) + cmd.AddCommand(alb.NewCmd(params)) + cmd.AddCommand(edge.NewCmd(params)) + cmd.AddCommand(intake.NewCmd(params)) + cmd.AddCommand(cdn.NewCmd(params)) } diff --git a/internal/cmd/beta/cdn/cdn.go b/internal/cmd/beta/cdn/cdn.go new file mode 100644 index 000000000..09d05af76 --- /dev/null +++ b/internal/cmd/beta/cdn/cdn.go @@ -0,0 +1,26 @@ +package cdn + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "cdn", + Short: "Manage CDN resources", + Long: "Manage the lifecycle of CDN resources.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(distribution.NewCommand(params)) +} diff --git a/internal/cmd/beta/cdn/distribution/create/create.go b/internal/cmd/beta/cdn/distribution/create/create.go new file mode 100644 index 000000000..d372af381 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/create/create.go @@ -0,0 +1,341 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + cdnUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + flagRegion = "regions" + flagHTTP = "http" + flagHTTPOriginURL = "http-origin-url" + flagHTTPGeofencing = "http-geofencing" + flagHTTPOriginRequestHeaders = "http-origin-request-headers" + flagBucket = "bucket" + flagBucketURL = "bucket-url" + flagBucketCredentialsAccessKeyID = "bucket-credentials-access-key-id" //nolint:gosec // linter false positive + flagBucketRegion = "bucket-region" + flagBlockedCountries = "blocked-countries" + flagBlockedIPs = "blocked-ips" + flagDefaultCacheDuration = "default-cache-duration" + flagLoki = "loki" + flagLokiUsername = "loki-username" + flagLokiPushURL = "loki-push-url" + flagMonthlyLimitBytes = "monthly-limit-bytes" + flagOptimizer = "optimizer" +) + +type httpInputModel struct { + OriginURL string + Geofencing *map[string][]string + OriginRequestHeaders *map[string]string +} + +type bucketInputModel struct { + URL string + AccessKeyID string + Password string + Region string +} + +type lokiInputModel struct { + Username string + Password string + PushURL string +} + +type inputModel struct { + *globalflags.GlobalFlagModel + Regions []cdn.Region + HTTP *httpInputModel + Bucket *bucketInputModel + BlockedCountries []string + BlockedIPs []string + DefaultCacheDuration string + MonthlyLimitBytes *int64 + Loki *lokiInputModel + Optimizer bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a CDN distribution", + Long: "Create a CDN distribution for a given originUrl in multiple regions.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a CDN distribution with an HTTP backend`, + `$ stackit beta cdn distribution create --http --http-origin-url https://example.com \ +--regions AF,EU`, + ), + examples.NewExample( + `Create a CDN distribution with an Object Storage backend`, + `$ stackit beta cdn distribution create --bucket --bucket-url https://bucket.example.com \ +--bucket-credentials-access-key-id yyyy --bucket-region EU \ +--regions AF,EU`, + ), + examples.NewExample( + `Create a CDN distribution passing the password via stdin, take care that there's a '\n' at the end of the input'`, + `$ cat secret.txt | stackit beta cdn distribution create -y --project-id xxx \ +--bucket --bucket-url https://bucket.example.com --bucekt-credentials-access-key-id yyyy --bucket-region EU \ +--regions AF,EU`, + ), + ), + PreRun: func(cmd *cobra.Command, _ []string) { + // either flagHTTP or flagBucket must be set, depending on which we mark other flags as required + if flags.FlagToBoolValue(params.Printer, cmd, flagHTTP) { + err := cmd.MarkFlagRequired(flagHTTPOriginURL) + cobra.CheckErr(err) + } else { + err := flags.MarkFlagsRequired(cmd, flagBucketURL, flagBucketCredentialsAccessKeyID, flagBucketRegion) + cobra.CheckErr(err) + } + // if user uses loki, mark related flags as required + if flags.FlagToBoolValue(params.Printer, cmd, flagLoki) { + err := flags.MarkFlagsRequired(cmd, flagLokiUsername, flagLokiPushURL) + cobra.CheckErr(err) + } + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + if model.Bucket != nil { + pw, err := params.Printer.PromptForPassword("enter your secret access key for the object storage bucket: ") + if err != nil { + return fmt.Errorf("reading secret access key: %w", err) + } + model.Bucket.Password = pw + } + if model.Loki != nil { + pw, err := params.Printer.PromptForPassword("enter your password for the loki log sink: ") + if err != nil { + return fmt.Errorf("reading loki password: %w", err) + } + model.Loki.Password = pw + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a CDN distribution for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create CDN distribution: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.EnumSliceFlag(false, []string{}, sdkUtils.EnumSliceToStringSlice(cdn.AllowedRegionEnumValues)...), flagRegion, fmt.Sprintf("Regions in which content should be cached, multiple of: %q", cdn.AllowedRegionEnumValues)) + cmd.Flags().Bool(flagHTTP, false, "Use HTTP backend") + cmd.Flags().String(flagHTTPOriginURL, "", "Origin URL for HTTP backend") + cmd.Flags().StringSlice(flagHTTPOriginRequestHeaders, []string{}, "Origin request headers for HTTP backend in the format 'HeaderName: HeaderValue', repeatable. WARNING: do not store sensitive values in the headers!") + cmd.Flags().StringArray(flagHTTPGeofencing, []string{}, "Geofencing rules for HTTP backend in the format 'https://example.com US,DE'. URL and countries have to be quoted. Repeatable.") + cmd.Flags().Bool(flagBucket, false, "Use Object Storage backend") + cmd.Flags().String(flagBucketURL, "", "Bucket URL for Object Storage backend") + cmd.Flags().String(flagBucketCredentialsAccessKeyID, "", "Access Key ID for Object Storage backend") + cmd.Flags().String(flagBucketRegion, "", "Region for Object Storage backend") + cmd.Flags().StringSlice(flagBlockedCountries, []string{}, "Comma-separated list of ISO 3166-1 alpha-2 country codes to block (e.g., 'US,DE,FR')") + cmd.Flags().StringSlice(flagBlockedIPs, []string{}, "Comma-separated list of IPv4 addresses to block (e.g., '10.0.0.8,127.0.0.1')") + cmd.Flags().String(flagDefaultCacheDuration, "", "ISO8601 duration string for default cache duration (e.g., 'PT1H30M' for 1 hour and 30 minutes)") + cmd.Flags().Bool(flagLoki, false, "Enable Loki log sink for the CDN distribution") + cmd.Flags().String(flagLokiUsername, "", "Username for log sink") + cmd.Flags().String(flagLokiPushURL, "", "Push URL for log sink") + cmd.Flags().Int64(flagMonthlyLimitBytes, 0, "Monthly limit in bytes for the CDN distribution") + cmd.Flags().Bool(flagOptimizer, false, "Enable optimizer for the CDN distribution (paid feature).") + cmd.MarkFlagsMutuallyExclusive(flagHTTP, flagBucket) + cmd.MarkFlagsOneRequired(flagHTTP, flagBucket) + err := flags.MarkFlagsRequired(cmd, flagRegion) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + regionStrings := flags.FlagToStringSliceValue(p, cmd, flagRegion) + regions := make([]cdn.Region, 0, len(regionStrings)) + for _, regionStr := range regionStrings { + regions = append(regions, cdn.Region(regionStr)) + } + + var http *httpInputModel + if flags.FlagToBoolValue(p, cmd, flagHTTP) { + originURL := flags.FlagToStringValue(p, cmd, flagHTTPOriginURL) + + var geofencing *map[string][]string + geofencingInput := flags.FlagToStringArrayValue(p, cmd, flagHTTPGeofencing) + if geofencingInput != nil { + geofencing = cdnUtils.ParseGeofencing(p, geofencingInput) + } + + var originRequestHeaders *map[string]string + originRequestHeadersInput := flags.FlagToStringSliceValue(p, cmd, flagHTTPOriginRequestHeaders) + if originRequestHeadersInput != nil { + originRequestHeaders = cdnUtils.ParseOriginRequestHeaders(p, originRequestHeadersInput) + } + + http = &httpInputModel{ + OriginURL: originURL, + Geofencing: geofencing, + OriginRequestHeaders: originRequestHeaders, + } + } + + var bucket *bucketInputModel + if flags.FlagToBoolValue(p, cmd, flagBucket) { + bucketURL := flags.FlagToStringValue(p, cmd, flagBucketURL) + accessKeyID := flags.FlagToStringValue(p, cmd, flagBucketCredentialsAccessKeyID) + region := flags.FlagToStringValue(p, cmd, flagBucketRegion) + + bucket = &bucketInputModel{ + URL: bucketURL, + AccessKeyID: accessKeyID, + Password: "", + Region: region, + } + } + + blockedCountries := flags.FlagToStringSliceValue(p, cmd, flagBlockedCountries) + blockedIPs := flags.FlagToStringSliceValue(p, cmd, flagBlockedIPs) + cacheDuration := flags.FlagToStringValue(p, cmd, flagDefaultCacheDuration) + monthlyLimit := flags.FlagToInt64Pointer(p, cmd, flagMonthlyLimitBytes) + + var loki *lokiInputModel + if flags.FlagToBoolValue(p, cmd, flagLoki) { + loki = &lokiInputModel{ + Username: flags.FlagToStringValue(p, cmd, flagLokiUsername), + PushURL: flags.FlagToStringValue(p, cmd, flagLokiPushURL), + Password: "", + } + } + + optimizer := flags.FlagToBoolValue(p, cmd, flagOptimizer) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Regions: regions, + HTTP: http, + Bucket: bucket, + BlockedCountries: blockedCountries, + BlockedIPs: blockedIPs, + DefaultCacheDuration: cacheDuration, + MonthlyLimitBytes: monthlyLimit, + Loki: loki, + Optimizer: optimizer, + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *cdn.APIClient) cdn.ApiCreateDistributionRequest { + req := apiClient.CreateDistribution(ctx, model.ProjectId) + var backend cdn.CreateDistributionPayloadGetBackendArgType + if model.HTTP != nil { + backend = cdn.CreateDistributionPayloadGetBackendArgType{ + HttpBackendCreate: &cdn.HttpBackendCreate{ + Geofencing: model.HTTP.Geofencing, + OriginRequestHeaders: model.HTTP.OriginRequestHeaders, + OriginUrl: &model.HTTP.OriginURL, + Type: utils.Ptr("http"), + }, + } + } else { + backend = cdn.CreateDistributionPayloadGetBackendArgType{ + BucketBackendCreate: &cdn.BucketBackendCreate{ + BucketUrl: &model.Bucket.URL, + Credentials: cdn.NewBucketCredentials( + model.Bucket.AccessKeyID, + model.Bucket.Password, + ), + Region: &model.Bucket.Region, + Type: utils.Ptr("bucket"), + }, + } + } + + payload := cdn.NewCreateDistributionPayload( + backend, + model.Regions, + ) + if len(model.BlockedCountries) > 0 { + payload.BlockedCountries = &model.BlockedCountries + } + if len(model.BlockedIPs) > 0 { + payload.BlockedIps = &model.BlockedIPs + } + if model.DefaultCacheDuration != "" { + payload.DefaultCacheDuration = utils.Ptr(model.DefaultCacheDuration) + } + if model.Loki != nil { + payload.LogSink = &cdn.CreateDistributionPayloadGetLogSinkArgType{ + LokiLogSinkCreate: &cdn.LokiLogSinkCreate{ + Credentials: &cdn.LokiLogSinkCredentials{ + Password: &model.Loki.Password, + Username: &model.Loki.Username, + }, + PushUrl: &model.Loki.PushURL, + Type: utils.Ptr("loki"), + }, + } + } + payload.MonthlyLimitBytes = model.MonthlyLimitBytes + if model.Optimizer { + payload.Optimizer = &cdn.CreateDistributionPayloadGetOptimizerArgType{ + Enabled: utils.Ptr(true), + } + } + return req.CreateDistributionPayload(*payload) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *cdn.CreateDistributionResponse) error { + if resp == nil { + return fmt.Errorf("create distribution response is nil") + } + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Created CDN distribution for %q. ID: %s\n", projectLabel, utils.PtrString(resp.Distribution.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/cdn/distribution/create/create_test.go b/internal/cmd/beta/cdn/distribution/create/create_test.go new file mode 100644 index 000000000..8d2140e6e --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/create/create_test.go @@ -0,0 +1,543 @@ +package create + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + "k8s.io/utils/ptr" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &cdn.APIClient{} +var testProjectId = uuid.NewString() + +const testRegions = cdn.REGION_EU + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + flagRegion: string(testRegions), + } + flagsHTTPBackend()(flagValues) + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func flagsHTTPBackend() func(flagValues map[string]string) { + return func(flagValues map[string]string) { + delete(flagValues, flagBucket) + flagValues[flagHTTP] = "true" + flagValues[flagHTTPOriginURL] = "https://http-backend.example.com" + } +} + +func flagsBucketBackend() func(flagValues map[string]string) { + return func(flagValues map[string]string) { + delete(flagValues, flagHTTP) + flagValues[flagBucket] = "true" + flagValues[flagBucketURL] = "https://bucket-backend.example.com" + flagValues[flagBucketCredentialsAccessKeyID] = "access-key-id" + flagValues[flagBucketRegion] = "eu" + } +} + +func flagsLoki() func(flagValues map[string]string) { + return func(flagValues map[string]string) { + flagValues[flagLoki] = "true" + flagValues[flagLokiPushURL] = "https://loki.example.com" + flagValues[flagLokiUsername] = "loki-user" + } +} + +func flagRegions(regions ...cdn.Region) func(flagValues map[string]string) { + return func(flagValues map[string]string) { + if len(regions) == 0 { + delete(flagValues, flagRegion) + return + } + stringRegions := sdkUtils.EnumSliceToStringSlice(regions) + flagValues[flagRegion] = strings.Join(stringRegions, ",") + } +} + +func fixtureModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Regions: []cdn.Region{testRegions}, + } + modelHTTPBackend()(model) + for _, mod := range mods { + mod(model) + } + return model +} + +func modelRegions(regions ...cdn.Region) func(model *inputModel) { + return func(model *inputModel) { + model.Regions = regions + } +} + +func modelHTTPBackend() func(model *inputModel) { + return func(model *inputModel) { + model.Bucket = nil + model.HTTP = &httpInputModel{ + OriginURL: "https://http-backend.example.com", + } + } +} + +func modelBucketBackend() func(model *inputModel) { + return func(model *inputModel) { + model.HTTP = nil + model.Bucket = &bucketInputModel{ + URL: "https://bucket-backend.example.com", + AccessKeyID: "access-key-id", + Region: "eu", + } + } +} + +func modelLoki() func(model *inputModel) { + return func(model *inputModel) { + model.Loki = &lokiInputModel{ + PushURL: "https://loki.example.com", + Username: "loki-user", + } + } +} + +func fixturePayload(mods ...func(payload *cdn.CreateDistributionPayload)) cdn.CreateDistributionPayload { + payload := *cdn.NewCreateDistributionPayload( + cdn.CreateDistributionPayloadGetBackendArgType{ + HttpBackendCreate: &cdn.HttpBackendCreate{ + Type: utils.Ptr("http"), + OriginUrl: utils.Ptr("https://http-backend.example.com"), + }, + }, + []cdn.Region{testRegions}, + ) + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func payloadRegions(regions ...cdn.Region) func(payload *cdn.CreateDistributionPayload) { + return func(payload *cdn.CreateDistributionPayload) { + payload.Regions = ®ions + } +} + +func payloadBucketBackend() func(payload *cdn.CreateDistributionPayload) { + return func(payload *cdn.CreateDistributionPayload) { + payload.Backend = &cdn.CreateDistributionPayloadGetBackendArgType{ + BucketBackendCreate: &cdn.BucketBackendCreate{ + Type: utils.Ptr("bucket"), + BucketUrl: utils.Ptr("https://bucket-backend.example.com"), + Region: utils.Ptr("eu"), + Credentials: cdn.NewBucketCredentials( + "access-key-id", + "", + ), + }, + } + } +} + +func payloadLoki() func(payload *cdn.CreateDistributionPayload) { + return func(payload *cdn.CreateDistributionPayload) { + payload.LogSink = &cdn.CreateDistributionPayloadGetLogSinkArgType{ + LokiLogSinkCreate: &cdn.LokiLogSinkCreate{ + Type: utils.Ptr("loki"), + PushUrl: utils.Ptr("https://loki.example.com"), + Credentials: cdn.NewLokiLogSinkCredentials("", "loki-user"), + }, + } + } +} + +func fixtureRequest(mods ...func(payload *cdn.CreateDistributionPayload)) cdn.ApiCreateDistributionRequest { + req := testClient.CreateDistribution(testCtx, testProjectId) + req = req.CreateDistributionPayload(fixturePayload(mods...)) + return req +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expected *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expected: fixtureModel(), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "regions missing", + flagValues: fixtureFlagValues(flagRegions()), + isValid: false, + }, + { + description: "multiple regions", + flagValues: fixtureFlagValues(flagRegions(cdn.REGION_EU, cdn.REGION_AF)), + isValid: true, + expected: fixtureModel(modelRegions(cdn.REGION_EU, cdn.REGION_AF)), + }, + { + description: "bucket backend", + flagValues: fixtureFlagValues(flagsBucketBackend()), + isValid: true, + expected: fixtureModel(modelBucketBackend()), + }, + { + description: "bucket backend missing url", + flagValues: fixtureFlagValues( + flagsBucketBackend(), + func(flagValues map[string]string) { + delete(flagValues, flagBucketURL) + }, + ), + isValid: false, + }, + { + description: "bucket backend missing access key id", + flagValues: fixtureFlagValues( + flagsBucketBackend(), + func(flagValues map[string]string) { + delete(flagValues, flagBucketCredentialsAccessKeyID) + }, + ), + isValid: false, + }, + { + description: "bucket backend missing region", + flagValues: fixtureFlagValues( + flagsBucketBackend(), + func(flagValues map[string]string) { + delete(flagValues, flagBucketRegion) + }, + ), + isValid: false, + }, + { + description: "http backend missing url", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + delete(flagValues, flagHTTPOriginURL) + }, + ), + isValid: false, + }, + { + description: "http backend with geofencing", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagHTTPGeofencing] = "https://dach.example.com DE,AT,CH" + }, + ), + isValid: true, + expected: fixtureModel( + func(model *inputModel) { + model.HTTP.Geofencing = &map[string][]string{ + "https://dach.example.com": {"DE", "AT", "CH"}, + } + }, + ), + }, + { + description: "http backend with origin request headers", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagHTTPOriginRequestHeaders] = "X-Custom-Header:Value1,X-Another-Header:Value2" + }, + ), + isValid: true, + expected: fixtureModel( + func(model *inputModel) { + model.HTTP.OriginRequestHeaders = &map[string]string{ + "X-Custom-Header": "Value1", + "X-Another-Header": "Value2", + } + }, + ), + }, + { + description: "with blocked countries", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagBlockedCountries] = "DE,AT" + }), + isValid: true, + expected: fixtureModel( + func(model *inputModel) { + model.BlockedCountries = []string{"DE", "AT"} + }, + ), + }, + { + description: "with blocked IPs", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagBlockedIPs] = "127.0.0.1,10.0.0.8" + }), + isValid: true, + expected: fixtureModel( + func(model *inputModel) { + model.BlockedIPs = []string{"127.0.0.1", "10.0.0.8"} + }), + }, + { + description: "with default cache duration", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagDefaultCacheDuration] = "PT1H30M" + }), + isValid: true, + expected: fixtureModel( + func(model *inputModel) { + model.DefaultCacheDuration = "PT1H30M" + }), + }, + { + description: "with optimizer", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagOptimizer] = "true" + }), + isValid: true, + expected: fixtureModel( + func(model *inputModel) { + model.Optimizer = true + }), + }, + { + description: "with loki", + flagValues: fixtureFlagValues( + flagsLoki(), + ), + isValid: true, + expected: fixtureModel( + modelLoki(), + ), + }, + { + description: "loki with missing username", + flagValues: fixtureFlagValues( + flagsLoki(), + func(flagValues map[string]string) { + delete(flagValues, flagLokiUsername) + }, + ), + isValid: false, + }, + { + description: "loki with missing push url", + flagValues: fixtureFlagValues( + flagsLoki(), + func(flagValues map[string]string) { + delete(flagValues, flagLokiPushURL) + }, + ), + isValid: false, + }, + { + description: "with monthly limit bytes", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagMonthlyLimitBytes] = "1073741824" // 1 GiB + }), + isValid: true, + expected: fixtureModel( + func(model *inputModel) { + model.MonthlyLimitBytes = ptr.To[int64](1073741824) + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expected, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected cdn.ApiCreateDistributionRequest + }{ + { + description: "base", + model: fixtureModel(), + expected: fixtureRequest(), + }, + { + description: "multiple regions", + model: fixtureModel(modelRegions(cdn.REGION_AF, cdn.REGION_EU)), + expected: fixtureRequest(payloadRegions(cdn.REGION_AF, cdn.REGION_EU)), + }, + { + description: "bucket backend", + model: fixtureModel(modelBucketBackend()), + expected: fixtureRequest(payloadBucketBackend()), + }, + { + description: "http backend with geofencing and origin request headers", + model: fixtureModel( + func(model *inputModel) { + model.HTTP.Geofencing = &map[string][]string{ + "https://dach.example.com": {"DE", "AT", "CH"}, + } + model.HTTP.OriginRequestHeaders = &map[string]string{ + "X-Custom-Header": "Value1", + "X-Another-Header": "Value2", + } + }, + ), + expected: fixtureRequest( + func(payload *cdn.CreateDistributionPayload) { + payload.Backend.HttpBackendCreate.Geofencing = &map[string][]string{ + "https://dach.example.com": {"DE", "AT", "CH"}, + } + payload.Backend.HttpBackendCreate.OriginRequestHeaders = &map[string]string{ + "X-Custom-Header": "Value1", + "X-Another-Header": "Value2", + } + }, + ), + }, + { + description: "with full options", + model: fixtureModel( + func(model *inputModel) { + model.MonthlyLimitBytes = ptr.To[int64](5368709120) // 5 GiB + model.Optimizer = true + model.BlockedCountries = []string{"DE", "AT"} + model.BlockedIPs = []string{"127.0.0.1"} + model.DefaultCacheDuration = "PT2H" + }, + ), + expected: fixtureRequest( + func(payload *cdn.CreateDistributionPayload) { + payload.MonthlyLimitBytes = utils.Ptr[int64](5368709120) + payload.Optimizer = &cdn.CreateDistributionPayloadGetOptimizerArgType{ + Enabled: utils.Ptr(true), + } + payload.BlockedCountries = &[]string{"DE", "AT"} + payload.BlockedIps = &[]string{"127.0.0.1"} + payload.DefaultCacheDuration = utils.Ptr("PT2H") + }, + ), + }, + { + description: "loki", + model: fixtureModel( + modelLoki(), + ), + expected: fixtureRequest(payloadLoki()), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expected, + cmp.AllowUnexported(tt.expected), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFormat string + response *cdn.CreateDistributionResponse + expected string + wantErr bool + }{ + { + description: "nil response", + outputFormat: "table", + response: nil, + wantErr: true, + }, + { + description: "table output", + outputFormat: "table", + response: &cdn.CreateDistributionResponse{ + Distribution: &cdn.Distribution{ + Id: ptr.To("dist-1234"), + }, + }, + expected: fmt.Sprintf("Created CDN distribution for %q. ID: dist-1234\n", testProjectId), + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + buffer := &bytes.Buffer{} + p.Cmd.SetOut(buffer) + if err := outputResult(p, tt.outputFormat, testProjectId, tt.response); (err != nil) != tt.wantErr { + t.Fatalf("outputResult: %v", err) + } + if buffer.String() != tt.expected { + t.Errorf("want:\n%s\ngot:\n%s", tt.expected, buffer.String()) + } + }) + } +} diff --git a/internal/cmd/beta/cdn/distribution/delete/delete.go b/internal/cmd/beta/cdn/distribution/delete/delete.go new file mode 100644 index 000000000..beffc6f48 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/delete/delete.go @@ -0,0 +1,93 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const argDistributionID = "DISTRIBUTION_ID" + +type inputModel struct { + *globalflags.GlobalFlagModel + DistributionID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a CDN distribution", + Long: "Delete a CDN distribution by its ID.", + Args: args.SingleArg(argDistributionID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a CDN distribution with ID "xxx"`, + `$ stackit beta cdn distribution delete xxx`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the CDN distribution %q for project %q?", model.DistributionID, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete loadbalancer: %w", err) + } + + params.Printer.Outputf("CDN distribution %q deleted.\n", model.DistributionID) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + distributionID := inputArgs[0] + model := inputModel{ + GlobalFlagModel: globalFlags, + DistributionID: distributionID, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *cdn.APIClient) cdn.ApiDeleteDistributionRequest { + return apiClient.DeleteDistribution(ctx, model.ProjectId, model.DistributionID) +} diff --git a/internal/cmd/beta/cdn/distribution/delete/delete_test.go b/internal/cmd/beta/cdn/distribution/delete/delete_test.go new file mode 100644 index 000000000..629110c8b --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/delete/delete_test.go @@ -0,0 +1,131 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectId = uuid.NewString() + testClient = &cdn.APIClient{} + testDistributionID = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argVales []string)) []string { + argVales := []string{ + testDistributionID, + } + for _, m := range mods { + m(argVales) + } + return argVales +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + DistributionID: testDistributionID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *cdn.ApiDeleteDistributionRequest)) cdn.ApiDeleteDistributionRequest { + request := testClient.DeleteDistribution(testCtx, testProjectId, testDistributionID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argsValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argsValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argsValues: []string{}, + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + }, + isValid: false, + }, + { + description: "no arg values", + argsValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedResult cdn.ApiDeleteDistributionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedResult: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedResult, + cmp.AllowUnexported(tt.expectedResult), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/cdn/distribution/describe/describe.go b/internal/cmd/beta/cdn/distribution/describe/describe.go new file mode 100644 index 000000000..7443553ee --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/describe/describe.go @@ -0,0 +1,220 @@ +package describe + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/spf13/cobra" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const distributionIDArg = "DISTRIBUTION_ID_ARG" +const flagWithWaf = "with-waf" + +type inputModel struct { + *globalflags.GlobalFlagModel + DistributionID string + WithWAF bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe a CDN distribution", + Long: "Describe a CDN distribution by its ID.", + Args: args.SingleArg(distributionIDArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a CDN distribution with ID "xxx"`, + `$ stackit beta cdn distribution describe xxx`, + ), + examples.NewExample( + `Get details of a CDN, including WAF details, for ID "xxx"`, + `$ stackit beta cdn distribution describe xxx --with-waf`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read distribution: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(flagWithWaf, false, "Include WAF details in the distribution description") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + DistributionID: inputArgs[0], + WithWAF: flags.FlagToBoolValue(p, cmd, flagWithWaf), + } + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *cdn.APIClient) cdn.ApiGetDistributionRequest { + return apiClient.GetDistribution(ctx, model.ProjectId, model.DistributionID).WithWafStatus(model.WithWAF) +} + +func outputResult(p *print.Printer, outputFormat string, distribution *cdn.GetDistributionResponse) error { + if distribution == nil { + return fmt.Errorf("distribution response is empty") + } + return p.OutputResult(outputFormat, distribution, func() error { + d := distribution.Distribution + var content []tables.Table + + content = append(content, buildDistributionTable(d)) + + if d.Waf != nil { + content = append(content, buildWAFTable(d)) + } + + err := tables.DisplayTables(p, content) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} + +func buildDistributionTable(d *cdn.Distribution) tables.Table { + regions := strings.Join(sdkUtils.EnumSliceToStringSlice(*d.Config.Regions), ", ") + defaultCacheDuration := "" + if d.Config.DefaultCacheDuration != nil && d.Config.DefaultCacheDuration.IsSet() { + defaultCacheDuration = *d.Config.DefaultCacheDuration.Get() + } + logSinkPushUrl := "" + if d.Config.LogSink != nil && d.Config.LogSink.LokiLogSink != nil { + logSinkPushUrl = *d.Config.LogSink.LokiLogSink.PushUrl + } + monthlyLimitBytes := "" + if d.Config.MonthlyLimitBytes != nil { + monthlyLimitBytes = fmt.Sprintf("%d", *d.Config.MonthlyLimitBytes) + } + optimizerEnabled := "" + if d.Config.Optimizer != nil { + optimizerEnabled = fmt.Sprintf("%t", *d.Config.Optimizer.Enabled) + } + table := tables.NewTable() + table.SetTitle("Distribution") + table.AddRow("ID", utils.PtrString(d.Id)) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(d.Status)) + table.AddSeparator() + table.AddRow("REGIONS", regions) + table.AddSeparator() + table.AddRow("CREATED AT", utils.PtrString(d.CreatedAt)) + table.AddSeparator() + table.AddRow("UPDATED AT", utils.PtrString(d.UpdatedAt)) + table.AddSeparator() + table.AddRow("PROJECT ID", utils.PtrString(d.ProjectId)) + table.AddSeparator() + if d.Errors != nil && len(*d.Errors) > 0 { + var errorDescriptions []string + for _, err := range *d.Errors { + errorDescriptions = append(errorDescriptions, *err.En) + } + table.AddRow("ERRORS", strings.Join(errorDescriptions, "\n")) + table.AddSeparator() + } + if d.Config.Backend.BucketBackend != nil { + b := d.Config.Backend.BucketBackend + table.AddRow("BACKEND TYPE", "BUCKET") + table.AddSeparator() + table.AddRow("BUCKET URL", utils.PtrString(b.BucketUrl)) + table.AddSeparator() + table.AddRow("BUCKET REGION", utils.PtrString(b.Region)) + table.AddSeparator() + } else if d.Config.Backend.HttpBackend != nil { + h := d.Config.Backend.HttpBackend + var geofencing []string + if h.Geofencing != nil { + for k, v := range *h.Geofencing { + geofencing = append(geofencing, fmt.Sprintf("%s: %s", k, strings.Join(v, ", "))) + } + } + slices.Sort(geofencing) + table.AddRow("BACKEND TYPE", "HTTP") + table.AddSeparator() + table.AddRow("HTTP ORIGIN URL", utils.PtrString(h.OriginUrl)) + table.AddSeparator() + if h.OriginRequestHeaders != nil { + table.AddRow("HTTP ORIGIN REQUEST HEADERS", utils.JoinStringMap(*h.OriginRequestHeaders, ": ", ", ")) + table.AddSeparator() + } + table.AddRow("HTTP GEOFENCING PROPERTIES", strings.Join(geofencing, "\n")) + table.AddSeparator() + } + table.AddRow("BLOCKED COUNTRIES", strings.Join(*d.Config.BlockedCountries, ", ")) + table.AddSeparator() + table.AddRow("BLOCKED IPS", strings.Join(*d.Config.BlockedIps, ", ")) + table.AddSeparator() + table.AddRow("DEFAULT CACHE DURATION", defaultCacheDuration) + table.AddSeparator() + table.AddRow("LOG SINK PUSH URL", logSinkPushUrl) + table.AddSeparator() + table.AddRow("MONTHLY LIMIT (BYTES)", monthlyLimitBytes) + table.AddSeparator() + table.AddRow("OPTIMIZER ENABLED", optimizerEnabled) + table.AddSeparator() + return table +} + +func buildWAFTable(d *cdn.Distribution) tables.Table { + table := tables.NewTable() + table.SetTitle("WAF") + for _, disabled := range *d.Waf.DisabledRules { + table.AddRow("DISABLED RULE ID", utils.PtrString(disabled.Id)) + table.AddSeparator() + } + for _, enabled := range *d.Waf.EnabledRules { + table.AddRow("ENABLED RULE ID", utils.PtrString(enabled.Id)) + table.AddSeparator() + } + for _, logOnly := range *d.Waf.LogOnlyRules { + table.AddRow("LOG-ONLY RULE ID", utils.PtrString(logOnly.Id)) + table.AddSeparator() + } + return table +} diff --git a/internal/cmd/beta/cdn/distribution/describe/describe_test.go b/internal/cmd/beta/cdn/distribution/describe/describe_test.go new file mode 100644 index 000000000..640fab303 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/describe/describe_test.go @@ -0,0 +1,410 @@ +package describe + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "test") + testProjectID = uuid.NewString() + testDistributionID = uuid.NewString() + testClient = &cdn.APIClient{} + testTime = time.Time{} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectID, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectID, + Verbosity: globalflags.VerbosityDefault, + }, + DistributionID: testDistributionID, + WithWAF: false, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureResponse(mods ...func(resp *cdn.GetDistributionResponse)) *cdn.GetDistributionResponse { + response := &cdn.GetDistributionResponse{ + Distribution: &cdn.Distribution{ + Config: &cdn.Config{ + Backend: &cdn.ConfigBackend{ + BucketBackend: &cdn.BucketBackend{ + BucketUrl: utils.Ptr("https://example.com"), + Region: utils.Ptr("eu"), + Type: utils.Ptr("bucket"), + }, + }, + BlockedCountries: utils.Ptr([]string{}), + BlockedIps: utils.Ptr([]string{}), + DefaultCacheDuration: nil, + LogSink: nil, + MonthlyLimitBytes: nil, + Optimizer: nil, + Regions: &[]cdn.Region{cdn.REGION_EU}, + Waf: nil, + }, + CreatedAt: utils.Ptr(testTime), + Domains: &[]cdn.Domain{}, + Errors: nil, + Id: utils.Ptr(testDistributionID), + ProjectId: utils.Ptr(testProjectID), + Status: utils.Ptr(cdn.DISTRIBUTIONSTATUS_ACTIVE), + UpdatedAt: utils.Ptr(testTime), + Waf: nil, + }, + } + for _, mod := range mods { + mod(response) + } + return response +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + args []string + flags map[string]string + isValid bool + expected *inputModel + }{ + { + description: "base", + args: []string{testDistributionID}, + flags: fixtureFlagValues(), + isValid: true, + expected: fixtureInputModel(), + }, + { + description: "no args", + args: []string{}, + flags: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid distribution id", + args: []string{"invalid-uuid"}, + flags: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing project id", + args: []string{testDistributionID}, + flags: map[string]string{}, + isValid: false, + }, + { + description: "invalid project id", + args: []string{testDistributionID}, + flags: map[string]string{ + globalflags.ProjectIdFlag: "invalid-uuid", + }, + isValid: false, + }, + { + description: "with WAF", + args: []string{testDistributionID}, + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[flagWithWaf] = "true" + }), + isValid: true, + expected: fixtureInputModel(func(model *inputModel) { + model.WithWAF = true + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expected, tt.args, tt.flags, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected cdn.ApiGetDistributionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expected: testClient.GetDistribution(testCtx, testProjectID, testDistributionID).WithWafStatus(false), + }, + { + description: "with WAF", + model: fixtureInputModel(func(model *inputModel) { + model.WithWAF = true + }), + expected: testClient.GetDistribution(testCtx, testProjectID, testDistributionID).WithWafStatus(true), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(got, tt.expected, + cmp.AllowUnexported(tt.expected), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + format string + distribution *cdn.GetDistributionResponse + wantErr bool + expected string + }{ + { + description: "empty", + format: "table", + wantErr: true, + }, + { + description: "no errors", + format: "table", + distribution: fixtureResponse(), + //nolint:staticcheck //you can't use escape sequences in ``-string-literals + expected: fmt.Sprintf(` + Distribution  + ID │ %-37s +────────────────────────┼────────────────────────────────────── + STATUS │ ACTIVE +────────────────────────┼────────────────────────────────────── + REGIONS │ EU +────────────────────────┼────────────────────────────────────── + CREATED AT │ %-37s +────────────────────────┼────────────────────────────────────── + UPDATED AT │ %-37s +────────────────────────┼────────────────────────────────────── + PROJECT ID │ %-37s +────────────────────────┼────────────────────────────────────── + BACKEND TYPE │ BUCKET +────────────────────────┼────────────────────────────────────── + BUCKET URL │ https://example.com +────────────────────────┼────────────────────────────────────── + BUCKET REGION │ eu +────────────────────────┼────────────────────────────────────── + BLOCKED COUNTRIES │ +────────────────────────┼────────────────────────────────────── + BLOCKED IPS │ +────────────────────────┼────────────────────────────────────── + DEFAULT CACHE DURATION │ +────────────────────────┼────────────────────────────────────── + LOG SINK PUSH URL │ +────────────────────────┼────────────────────────────────────── + MONTHLY LIMIT (BYTES) │ +────────────────────────┼────────────────────────────────────── + OPTIMIZER ENABLED │ + +`, + testDistributionID, + testTime, + testTime, + testProjectID), + }, + { + description: "with errors", + format: "table", + distribution: fixtureResponse( + func(r *cdn.GetDistributionResponse) { + r.Distribution.Errors = &[]cdn.StatusError{ + { + En: utils.Ptr("First error message"), + }, + { + En: utils.Ptr("Second error message"), + }, + } + }, + ), + //nolint:staticcheck //you can't use escape sequences in ``-string-literals + expected: fmt.Sprintf(` + Distribution  + ID │ %-37s +────────────────────────┼────────────────────────────────────── + STATUS │ ACTIVE +────────────────────────┼────────────────────────────────────── + REGIONS │ EU +────────────────────────┼────────────────────────────────────── + CREATED AT │ %-37s +────────────────────────┼────────────────────────────────────── + UPDATED AT │ %-37s +────────────────────────┼────────────────────────────────────── + PROJECT ID │ %-37s +────────────────────────┼────────────────────────────────────── + ERRORS │ First error message + │ Second error message +────────────────────────┼────────────────────────────────────── + BACKEND TYPE │ BUCKET +────────────────────────┼────────────────────────────────────── + BUCKET URL │ https://example.com +────────────────────────┼────────────────────────────────────── + BUCKET REGION │ eu +────────────────────────┼────────────────────────────────────── + BLOCKED COUNTRIES │ +────────────────────────┼────────────────────────────────────── + BLOCKED IPS │ +────────────────────────┼────────────────────────────────────── + DEFAULT CACHE DURATION │ +────────────────────────┼────────────────────────────────────── + LOG SINK PUSH URL │ +────────────────────────┼────────────────────────────────────── + MONTHLY LIMIT (BYTES) │ +────────────────────────┼────────────────────────────────────── + OPTIMIZER ENABLED │ + +`, testDistributionID, + testTime, + testTime, + testProjectID), + }, + { + description: "full", + format: "table", + distribution: fixtureResponse( + func(r *cdn.GetDistributionResponse) { + r.Distribution.Waf = &cdn.DistributionWaf{ + EnabledRules: &[]cdn.WafStatusRuleBlock{ + {Id: utils.Ptr("rule-id-1")}, + {Id: utils.Ptr("rule-id-2")}, + }, + DisabledRules: &[]cdn.WafStatusRuleBlock{ + {Id: utils.Ptr("rule-id-3")}, + {Id: utils.Ptr("rule-id-4")}, + }, + LogOnlyRules: &[]cdn.WafStatusRuleBlock{ + {Id: utils.Ptr("rule-id-5")}, + {Id: utils.Ptr("rule-id-6")}, + }, + } + r.Distribution.Config.Backend = &cdn.ConfigBackend{ + HttpBackend: &cdn.HttpBackend{ + OriginUrl: utils.Ptr("https://origin.example.com"), + OriginRequestHeaders: &map[string]string{ + "X-Custom-Header": "CustomValue", + }, + Geofencing: &map[string][]string{ + "origin1.example.com": {"US", "CA"}, + "origin2.example.com": {"FR", "DE"}, + }, + }, + } + r.Distribution.Config.BlockedCountries = &[]string{"US", "CN"} + r.Distribution.Config.BlockedIps = &[]string{"127.0.0.1"} + r.Distribution.Config.DefaultCacheDuration = cdn.NewNullableString(utils.Ptr("P1DT2H30M")) + r.Distribution.Config.LogSink = &cdn.ConfigLogSink{ + LokiLogSink: &cdn.LokiLogSink{ + PushUrl: utils.Ptr("https://logs.example.com"), + }, + } + r.Distribution.Config.MonthlyLimitBytes = utils.Ptr(int64(104857600)) + r.Distribution.Config.Optimizer = &cdn.Optimizer{ + Enabled: utils.Ptr(true), + } + }), + //nolint:staticcheck //you can't use escape sequences in ``-string-literals + expected: fmt.Sprintf(` + Distribution  + ID │ %-37s +─────────────────────────────┼────────────────────────────────────── + STATUS │ ACTIVE +─────────────────────────────┼────────────────────────────────────── + REGIONS │ EU +─────────────────────────────┼────────────────────────────────────── + CREATED AT │ %-37s +─────────────────────────────┼────────────────────────────────────── + UPDATED AT │ %-37s +─────────────────────────────┼────────────────────────────────────── + PROJECT ID │ %-37s +─────────────────────────────┼────────────────────────────────────── + BACKEND TYPE │ HTTP +─────────────────────────────┼────────────────────────────────────── + HTTP ORIGIN URL │ https://origin.example.com +─────────────────────────────┼────────────────────────────────────── + HTTP ORIGIN REQUEST HEADERS │ X-Custom-Header: CustomValue +─────────────────────────────┼────────────────────────────────────── + HTTP GEOFENCING PROPERTIES │ origin1.example.com: US, CA + │ origin2.example.com: FR, DE +─────────────────────────────┼────────────────────────────────────── + BLOCKED COUNTRIES │ US, CN +─────────────────────────────┼────────────────────────────────────── + BLOCKED IPS │ 127.0.0.1 +─────────────────────────────┼────────────────────────────────────── + DEFAULT CACHE DURATION │ P1DT2H30M +─────────────────────────────┼────────────────────────────────────── + LOG SINK PUSH URL │ https://logs.example.com +─────────────────────────────┼────────────────────────────────────── + MONTHLY LIMIT (BYTES) │ 104857600 +─────────────────────────────┼────────────────────────────────────── + OPTIMIZER ENABLED │ true + + + WAF  + DISABLED RULE ID │ rule-id-3 +──────────────────┼─────────── + DISABLED RULE ID │ rule-id-4 +──────────────────┼─────────── + ENABLED RULE ID │ rule-id-1 +──────────────────┼─────────── + ENABLED RULE ID │ rule-id-2 +──────────────────┼─────────── + LOG-ONLY RULE ID │ rule-id-5 +──────────────────┼─────────── + LOG-ONLY RULE ID │ rule-id-6 + +`, testDistributionID, testTime, testTime, testProjectID), + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var buf bytes.Buffer + p.Cmd.SetOut(&buf) + if err := outputResult(p, tt.format, tt.distribution); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + diff := cmp.Diff(buf.String(), tt.expected) + if diff != "" { + t.Fatalf("outputResult() output mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/cdn/distribution/distribution.go b/internal/cmd/beta/cdn/distribution/distribution.go new file mode 100644 index 000000000..c6cb8e018 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/distribution.go @@ -0,0 +1,33 @@ +package distribution + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/cdn/distribution/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCommand(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "distribution", + Short: "Manage CDN distributions", + Long: "Manage the lifecycle of CDN distributions.", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) +} diff --git a/internal/cmd/beta/cdn/distribution/list/list.go b/internal/cmd/beta/cdn/distribution/list/list.go new file mode 100644 index 000000000..41117b279 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/list/list.go @@ -0,0 +1,176 @@ +package list + +import ( + "context" + "fmt" + "math" + "strings" + + "github.com/spf13/cobra" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SortBy string + Limit *int32 +} + +const ( + sortByFlag = "sort-by" + limitFlag = "" + maxPageSize = int32(100) +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List CDN distributions", + Long: "List all CDN distributions in your account.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all CDN distributions`, + `$ stackit beta cdn distribution list`, + ), + examples.NewExample( + `List all CDN distributions sorted by id`, + `$ stackit beta cdn distribution list --sort-by=id`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + distributions, err := fetchDistributions(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("fetch distributions: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, distributions) + }, + } + + configureFlags(cmd) + return cmd +} + +var sortByFlagOptions = []string{"id", "createdAt", "updatedAt", "originUrl", "status", "originUrlRelated"} + +func configureFlags(cmd *cobra.Command) { + // same default as apiClient + cmd.Flags().Var(flags.EnumFlag(false, "createdAt", sortByFlagOptions...), sortByFlag, fmt.Sprintf("Sort entries by a specific field, one of %q", sortByFlagOptions)) + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt32Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SortBy: flags.FlagWithDefaultToStringValue(p, cmd, sortByFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *cdn.APIClient, nextPageID cdn.ListDistributionsResponseGetNextPageIdentifierAttributeType, pageLimit int32) cdn.ApiListDistributionsRequest { + req := apiClient.ListDistributions(ctx, model.ProjectId) + req = req.SortBy(model.SortBy) + req = req.PageSize(pageLimit) + if nextPageID != nil { + req = req.PageIdentifier(*nextPageID) + } + return req +} + +func outputResult(p *print.Printer, outputFormat string, distributions []cdn.Distribution) error { + if distributions == nil { + distributions = make([]cdn.Distribution, 0) // otherwise prints null in json output + } + return p.OutputResult(outputFormat, distributions, func() error { + if len(distributions) == 0 { + p.Outputln("No CDN distributions found") + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "REGIONS", "STATUS") + for _, d := range distributions { + var joinedRegions string + if d.Config != nil && d.Config.Regions != nil { + joinedRegions = strings.Join(sdkUtils.EnumSliceToStringSlice(*d.Config.Regions), ", ") + } + table.AddRow( + utils.PtrString(d.Id), + joinedRegions, + utils.PtrString(d.Status), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} + +func fetchDistributions(ctx context.Context, model *inputModel, apiClient *cdn.APIClient) ([]cdn.Distribution, error) { + var nextPageID cdn.ListDistributionsResponseGetNextPageIdentifierAttributeType + var distributions []cdn.Distribution + received := int32(0) + limit := int32(math.MaxInt32) + if model.Limit != nil { + limit = min(limit, *model.Limit) + } + for { + want := min(maxPageSize, limit-received) + request := buildRequest(ctx, model, apiClient, nextPageID, want) + response, err := request.Execute() + if err != nil { + return nil, fmt.Errorf("list distributions: %w", err) + } + if response.Distributions != nil { + distributions = append(distributions, *response.Distributions...) + } + nextPageID = response.NextPageIdentifier + received += want + if nextPageID == nil || received >= limit { + break + } + } + return distributions, nil +} diff --git a/internal/cmd/beta/cdn/distribution/list/list_test.go b/internal/cmd/beta/cdn/distribution/list/list_test.go new file mode 100644 index 000000000..7eae777a6 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/list/list_test.go @@ -0,0 +1,472 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "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/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testProjectId = uuid.NewString() +var testClient = &cdn.APIClient{} +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + +const ( + testNextPageID = "next-page-id-123" + testID = "dist-1" + testStatus = cdn.DISTRIBUTIONSTATUS_ACTIVE +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + m := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + SortBy: "createdAt", + } + for _, mod := range mods { + mod(m) + } + return m +} + +func fixtureRequest(mods ...func(request cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest { + request := testClient.ListDistributions(testCtx, testProjectId) + request = request.PageSize(100) + request = request.SortBy("createdAt") + for _, mod := range mods { + request = mod(request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expected *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expected: fixtureInputModel(), + }, + { + description: "no project id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "sort by id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "id" + }), + isValid: true, + expected: fixtureInputModel(func(model *inputModel) { + model.SortBy = "id" + }), + }, + { + description: "sort by origin-url", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "originUrl" + }), + isValid: true, + expected: fixtureInputModel(func(model *inputModel) { + model.SortBy = "originUrl" + }), + }, + { + description: "sort by status", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "status" + }), + isValid: true, + expected: fixtureInputModel(func(model *inputModel) { + model.SortBy = "status" + }), + }, + { + description: "sort by created", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "createdAt" + }), + isValid: true, + expected: fixtureInputModel(func(model *inputModel) { + model.SortBy = "createdAt" + }), + }, + { + description: "sort by updated", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "updatedAt" + }), + isValid: true, + expected: fixtureInputModel(func(model *inputModel) { + model.SortBy = "updatedAt" + }), + }, + { + description: "sort by originUrlRelated", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "originUrlRelated" + }), + isValid: true, + expected: fixtureInputModel(func(model *inputModel) { + model.SortBy = "originUrlRelated" + }), + }, + { + description: "invalid sort by", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "invalid" + }), + isValid: false, + }, + { + description: "missing sort by uses default", + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + delete(flagValues, sortByFlag) + }, + ), + isValid: true, + expected: fixtureInputModel(func(model *inputModel) { + model.SortBy = "createdAt" + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expected, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + inputModel *inputModel + nextPageID *string + expected cdn.ApiListDistributionsRequest + }{ + { + description: "base", + inputModel: fixtureInputModel(), + expected: fixtureRequest(), + }, + { + description: "sort by updatedAt", + inputModel: fixtureInputModel(func(model *inputModel) { + model.SortBy = "updatedAt" + }), + expected: fixtureRequest(func(req cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest { + return req.SortBy("updatedAt") + }), + }, + { + description: "with next page id", + inputModel: fixtureInputModel(), + nextPageID: utils.Ptr(testNextPageID), + expected: fixtureRequest(func(req cdn.ApiListDistributionsRequest) cdn.ApiListDistributionsRequest { + return req.PageIdentifier(testNextPageID) + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + req := buildRequest(testCtx, tt.inputModel, testClient, tt.nextPageID, maxPageSize) + diff := cmp.Diff(req, tt.expected, + cmp.AllowUnexported(tt.expected), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Errorf("buildRequest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +type testResponse struct { + statusCode int + body cdn.ListDistributionsResponse +} + +func fixtureTestResponse(mods ...func(resp *testResponse)) testResponse { + resp := testResponse{ + statusCode: 200, + } + for _, mod := range mods { + mod(&resp) + } + return resp +} + +func fixtureDistributions(count int) []cdn.Distribution { + distributions := make([]cdn.Distribution, count) + for i := 0; i < count; i++ { + id := fmt.Sprintf("dist-%d", i+1) + distributions[i] = cdn.Distribution{ + Id: &id, + } + } + return distributions +} + +func TestFetchDistributions(t *testing.T) { + tests := []struct { + description string + limit int32 + responses []testResponse + expected []cdn.Distribution + fails bool + }{ + { + description: "no distributions", + responses: []testResponse{ + fixtureTestResponse(), + }, + expected: nil, + }, + { + description: "single distribution, single page", + responses: []testResponse{ + fixtureTestResponse( + func(resp *testResponse) { + resp.body.Distributions = &[]cdn.Distribution{ + {Id: utils.Ptr("dist-1")}, + } + }, + ), + }, + expected: []cdn.Distribution{ + {Id: utils.Ptr("dist-1")}, + }, + }, + { + description: "multiple distributions, multiple pages", + responses: []testResponse{ + fixtureTestResponse( + func(resp *testResponse) { + resp.body.NextPageIdentifier = utils.Ptr(testNextPageID) + resp.body.Distributions = &[]cdn.Distribution{ + {Id: utils.Ptr("dist-1")}, + } + }, + ), + fixtureTestResponse( + func(resp *testResponse) { + resp.body.Distributions = &[]cdn.Distribution{ + {Id: utils.Ptr("dist-2")}, + } + }, + ), + }, + expected: []cdn.Distribution{ + {Id: utils.Ptr("dist-1")}, + {Id: utils.Ptr("dist-2")}, + }, + }, + { + description: "API error", + responses: []testResponse{ + fixtureTestResponse( + func(resp *testResponse) { + resp.statusCode = 500 + }, + ), + }, + fails: true, + }, + { + description: "API error on second page", + responses: []testResponse{ + fixtureTestResponse( + func(resp *testResponse) { + resp.body.NextPageIdentifier = utils.Ptr(testNextPageID) + resp.body.Distributions = &[]cdn.Distribution{ + {Id: utils.Ptr("dist-1")}, + } + }, + ), + fixtureTestResponse( + func(resp *testResponse) { + resp.statusCode = 500 + }, + ), + }, + fails: true, + }, + { + description: "limit across 2 pages", + limit: 110, + responses: []testResponse{ + fixtureTestResponse( + func(resp *testResponse) { + resp.body.NextPageIdentifier = utils.Ptr(testNextPageID) + distributions := fixtureDistributions(100) + resp.body.Distributions = &distributions + }, + ), + fixtureTestResponse( + func(resp *testResponse) { + distributions := fixtureDistributions(10) + resp.body.Distributions = &distributions + }, + ), + }, + expected: slices.Concat(fixtureDistributions(100), fixtureDistributions(10)), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + callCount := 0 + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := tt.responses[callCount] + callCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.statusCode) + bs, err := json.Marshal(resp.body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + _, err = w.Write(bs) + if err != nil { + t.Fatalf("write: %v", err) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + client, err := cdn.NewAPIClient( + sdkConfig.WithEndpoint(server.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("failed to create test client: %v", err) + } + var mods []func(m *inputModel) + if tt.limit > 0 { + mods = append(mods, func(m *inputModel) { + m.Limit = utils.Ptr(tt.limit) + }) + } + model := fixtureInputModel(mods...) + got, err := fetchDistributions(testCtx, model, client) + if err != nil { + if !tt.fails { + t.Fatalf("fetchDistributions() unexpected error: %v", err) + } + return + } + if callCount != len(tt.responses) { + t.Errorf("fetchDistributions() expected %d calls, got %d", len(tt.responses), callCount) + } + diff := cmp.Diff(got, tt.expected) + if diff != "" { + t.Errorf("fetchDistributions() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFormat string + distributions []cdn.Distribution + expected string + }{ + { + description: "no distributions", + outputFormat: "json", + distributions: []cdn.Distribution{}, + expected: `[] + +`, + }, + { + description: "no distributions nil slice", + outputFormat: "json", + expected: `[] + +`, + }, + { + description: "single distribution", + outputFormat: "table", + distributions: []cdn.Distribution{ + { + Id: utils.Ptr(testID), + Config: &cdn.Config{ + Regions: &[]cdn.Region{ + cdn.REGION_EU, + cdn.REGION_AF, + }, + }, + Status: utils.Ptr(testStatus), + }, + }, + expected: ` + ID │ REGIONS │ STATUS +────────┼─────────┼──────── + dist-1 │ EU, AF │ ACTIVE + +`, + }, + { + description: "no distributions, table format", + outputFormat: "table", + expected: "No CDN distributions found\n", + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + buffer := &bytes.Buffer{} + p.Cmd.SetOut(buffer) + if err := outputResult(p, tt.outputFormat, tt.distributions); err != nil { + t.Fatalf("outputResult: %v", err) + } + if buffer.String() != tt.expected { + t.Errorf("want:\n%s\ngot:\n%s", tt.expected, buffer.String()) + } + }) + } +} diff --git a/internal/cmd/beta/cdn/distribution/update/update.go b/internal/cmd/beta/cdn/distribution/update/update.go new file mode 100644 index 000000000..5bb66942f --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/update/update.go @@ -0,0 +1,338 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/client" + cdnUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/cdn/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + argDistributionID = "DISTRIBUTION_ID" + flagRegions = "regions" + flagHTTP = "http" + flagHTTPOriginURL = "http-origin-url" + flagHTTPGeofencing = "http-geofencing" + flagHTTPOriginRequestHeaders = "http-origin-request-headers" + flagBucket = "bucket" + flagBucketURL = "bucket-url" + flagBucketCredentialsAccessKeyID = "bucket-credentials-access-key-id" //nolint:gosec // linter false positive + flagBucketRegion = "bucket-region" + flagBlockedCountries = "blocked-countries" + flagBlockedIPs = "blocked-ips" + flagDefaultCacheDuration = "default-cache-duration" + flagLoki = "loki" + flagLokiUsername = "loki-username" + flagLokiPushURL = "loki-push-url" + flagMonthlyLimitBytes = "monthly-limit-bytes" + flagOptimizer = "optimizer" +) + +type bucketInputModel struct { + URL string + AccessKeyID string + Password string + Region string +} + +type httpInputModel struct { + Geofencing *map[string][]string + OriginRequestHeaders *map[string]string + OriginURL string +} + +type lokiInputModel struct { + Password string + Username string + PushURL string +} + +type inputModel struct { + *globalflags.GlobalFlagModel + DistributionID string + Regions []cdn.Region + Bucket *bucketInputModel + HTTP *httpInputModel + BlockedCountries []string + BlockedIPs []string + DefaultCacheDuration string + MonthlyLimitBytes *int64 + Loki *lokiInputModel + Optimizer *bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update a CDN distribution", + Long: "Update a CDN distribution by its ID, allowing replacement of its regions.", + Args: args.SingleArg(argDistributionID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `update a CDN distribution with ID "xxx" to not use optimizer`, + `$ stackit beta cdn distribution update xxx --optimizer=false`, + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + if model.Bucket != nil { + pw, err := params.Printer.PromptForPassword("enter your secret access key for the object storage bucket: ") + if err != nil { + return fmt.Errorf("reading secret access key: %w", err) + } + model.Bucket.Password = pw + } + if model.Loki != nil { + pw, err := params.Printer.PromptForPassword("enter your password for the loki log sink: ") + if err != nil { + return fmt.Errorf("reading loki password: %w", err) + } + model.Loki.Password = pw + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to update a CDN distribution for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req := buildRequest(ctx, apiClient, model) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update CDN distribution: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.EnumSliceFlag(false, []string{}, sdkUtils.EnumSliceToStringSlice(cdn.AllowedRegionEnumValues)...), flagRegions, fmt.Sprintf("Regions in which content should be cached, multiple of: %q", cdn.AllowedRegionEnumValues)) + cmd.Flags().Bool(flagHTTP, false, "Use HTTP backend") + cmd.Flags().String(flagHTTPOriginURL, "", "Origin URL for HTTP backend") + cmd.Flags().StringSlice(flagHTTPOriginRequestHeaders, []string{}, "Origin request headers for HTTP backend in the format 'HeaderName: HeaderValue', repeatable. WARNING: do not store sensitive values in the headers!") + cmd.Flags().StringArray(flagHTTPGeofencing, []string{}, "Geofencing rules for HTTP backend in the format 'https://example.com US,DE'. URL and countries have to be quoted. Repeatable.") + cmd.Flags().Bool(flagBucket, false, "Use Object Storage backend") + cmd.Flags().String(flagBucketURL, "", "Bucket URL for Object Storage backend") + cmd.Flags().String(flagBucketCredentialsAccessKeyID, "", "Access Key ID for Object Storage backend") + cmd.Flags().String(flagBucketRegion, "", "Region for Object Storage backend") + cmd.Flags().StringSlice(flagBlockedCountries, []string{}, "Comma-separated list of ISO 3166-1 alpha-2 country codes to block (e.g., 'US,DE,FR')") + cmd.Flags().StringSlice(flagBlockedIPs, []string{}, "Comma-separated list of IPv4 addresses to block (e.g., '10.0.0.8,127.0.0.1')") + cmd.Flags().String(flagDefaultCacheDuration, "", "ISO8601 duration string for default cache duration (e.g., 'PT1H30M' for 1 hour and 30 minutes)") + cmd.Flags().Bool(flagLoki, false, "Enable Loki log sink for the CDN distribution") + cmd.Flags().String(flagLokiUsername, "", "Username for log sink") + cmd.Flags().String(flagLokiPushURL, "", "Push URL for log sink") + cmd.Flags().Int64(flagMonthlyLimitBytes, 0, "Monthly limit in bytes for the CDN distribution") + cmd.Flags().Bool(flagOptimizer, false, "Enable optimizer for the CDN distribution (paid feature).") + cmd.MarkFlagsMutuallyExclusive(flagHTTP, flagBucket) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + distributionID := inputArgs[0] + + regionStrings := flags.FlagToStringSliceValue(p, cmd, flagRegions) + regions := make([]cdn.Region, 0, len(regionStrings)) + for _, regionStr := range regionStrings { + regions = append(regions, cdn.Region(regionStr)) + } + + var http *httpInputModel + if flags.FlagToBoolValue(p, cmd, flagHTTP) { + originURL := flags.FlagToStringValue(p, cmd, flagHTTPOriginURL) + + var geofencing *map[string][]string + geofencingInput := flags.FlagToStringArrayValue(p, cmd, flagHTTPGeofencing) + if geofencingInput != nil { + geofencing = cdnUtils.ParseGeofencing(p, geofencingInput) + } + + var originRequestHeaders *map[string]string + originRequestHeadersInput := flags.FlagToStringSliceValue(p, cmd, flagHTTPOriginRequestHeaders) + if originRequestHeadersInput != nil { + originRequestHeaders = cdnUtils.ParseOriginRequestHeaders(p, originRequestHeadersInput) + } + + http = &httpInputModel{ + OriginURL: originURL, + Geofencing: geofencing, + OriginRequestHeaders: originRequestHeaders, + } + } + + var bucket *bucketInputModel + if flags.FlagToBoolValue(p, cmd, flagBucket) { + bucketURL := flags.FlagToStringValue(p, cmd, flagBucketURL) + accessKeyID := flags.FlagToStringValue(p, cmd, flagBucketCredentialsAccessKeyID) + region := flags.FlagToStringValue(p, cmd, flagBucketRegion) + + bucket = &bucketInputModel{ + URL: bucketURL, + AccessKeyID: accessKeyID, + Password: "", + Region: region, + } + } + + blockedCountries := flags.FlagToStringSliceValue(p, cmd, flagBlockedCountries) + blockedIPs := flags.FlagToStringSliceValue(p, cmd, flagBlockedIPs) + cacheDuration := flags.FlagToStringValue(p, cmd, flagDefaultCacheDuration) + monthlyLimit := flags.FlagToInt64Pointer(p, cmd, flagMonthlyLimitBytes) + + var loki *lokiInputModel + if flags.FlagToBoolValue(p, cmd, flagLoki) { + loki = &lokiInputModel{ + Username: flags.FlagToStringValue(p, cmd, flagLokiUsername), + PushURL: flags.FlagToStringValue(p, cmd, flagLokiPushURL), + Password: "", + } + } + + var optimizer *bool + if cmd.Flags().Changed(flagOptimizer) { + o := flags.FlagToBoolValue(p, cmd, flagOptimizer) + optimizer = &o + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DistributionID: distributionID, + Regions: regions, + HTTP: http, + Bucket: bucket, + BlockedCountries: blockedCountries, + BlockedIPs: blockedIPs, + DefaultCacheDuration: cacheDuration, + MonthlyLimitBytes: monthlyLimit, + Loki: loki, + Optimizer: optimizer, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, apiClient *cdn.APIClient, model *inputModel) cdn.ApiPatchDistributionRequest { + req := apiClient.PatchDistribution(ctx, model.ProjectId, model.DistributionID) + payload := cdn.NewPatchDistributionPayload() + cfg := &cdn.ConfigPatch{} + payload.Config = cfg + if len(model.Regions) > 0 { + cfg.Regions = &model.Regions + } + if model.Bucket != nil { + bucket := &cdn.BucketBackendPatch{ + Type: utils.Ptr("bucket"), + } + cfg.Backend = &cdn.ConfigPatchBackend{ + BucketBackendPatch: bucket, + } + if model.Bucket.URL != "" { + bucket.BucketUrl = utils.Ptr(model.Bucket.URL) + } + if model.Bucket.AccessKeyID != "" { + bucket.Credentials = cdn.NewBucketCredentials( + model.Bucket.AccessKeyID, + model.Bucket.Password, + ) + } + if model.Bucket.Region != "" { + bucket.Region = utils.Ptr(model.Bucket.Region) + } + } else if model.HTTP != nil { + http := &cdn.HttpBackendPatch{ + Type: utils.Ptr("http"), + } + cfg.Backend = &cdn.ConfigPatchBackend{ + HttpBackendPatch: http, + } + if model.HTTP.OriginRequestHeaders != nil { + http.OriginRequestHeaders = model.HTTP.OriginRequestHeaders + } + if model.HTTP.Geofencing != nil { + http.Geofencing = model.HTTP.Geofencing + } + if model.HTTP.OriginURL != "" { + http.OriginUrl = utils.Ptr(model.HTTP.OriginURL) + } + } + if len(model.BlockedCountries) > 0 { + cfg.BlockedCountries = &model.BlockedCountries + } + if len(model.BlockedIPs) > 0 { + cfg.BlockedIps = &model.BlockedIPs + } + if model.DefaultCacheDuration != "" { + cfg.DefaultCacheDuration = cdn.NewNullableString(&model.DefaultCacheDuration) + } + if model.MonthlyLimitBytes != nil && *model.MonthlyLimitBytes > 0 { + cfg.MonthlyLimitBytes = model.MonthlyLimitBytes + } + if model.Loki != nil { + loki := &cdn.LokiLogSinkPatch{} + cfg.LogSink = cdn.NewNullableConfigPatchLogSink(&cdn.ConfigPatchLogSink{ + LokiLogSinkPatch: loki, + }) + if model.Loki.PushURL != "" { + loki.PushUrl = utils.Ptr(model.Loki.PushURL) + } + if model.Loki.Username != "" { + loki.Credentials = cdn.NewLokiLogSinkCredentials( + model.Loki.Password, + model.Loki.Username, + ) + } + } + if model.Optimizer != nil { + cfg.Optimizer = &cdn.OptimizerPatch{ + Enabled: model.Optimizer, + } + } + req = req.PatchDistributionPayload(*payload) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *cdn.PatchDistributionResponse) error { + if resp == nil { + return fmt.Errorf("update distribution response is empty") + } + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Updated CDN distribution for %q. ID: %s\n", projectLabel, utils.PtrString(resp.Distribution.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/cdn/distribution/update/update_test.go b/internal/cmd/beta/cdn/distribution/update/update_test.go new file mode 100644 index 000000000..915f908d7 --- /dev/null +++ b/internal/cmd/beta/cdn/distribution/update/update_test.go @@ -0,0 +1,366 @@ +package update + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + "k8s.io/utils/ptr" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const testCacheDuration = "P1DT12H" + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &cdn.APIClient{} +var testProjectId = uuid.NewString() +var testDistributionID = uuid.NewString() + +const testMonthlyLimitBytes int64 = 1048576 + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + }, + DistributionID: testDistributionID, + Regions: []cdn.Region{}, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(payload *cdn.PatchDistributionPayload)) cdn.ApiPatchDistributionRequest { + req := testClient.PatchDistribution(testCtx, testProjectId, testDistributionID) + if payload := fixturePayload(mods...); payload != nil { + req = req.PatchDistributionPayload(*fixturePayload(mods...)) + } + return req +} + +func fixturePayload(mods ...func(payload *cdn.PatchDistributionPayload)) *cdn.PatchDistributionPayload { + payload := cdn.NewPatchDistributionPayload() + payload.Config = &cdn.ConfigPatch{} + for _, m := range mods { + m(payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expected *inputModel + }{ + { + description: "base", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues(), + isValid: true, + expected: fixtureInputModel(), + }, + { + description: "distribution id missing", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid distribution id", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { delete(flagValues, globalflags.ProjectIdFlag) }), + isValid: false, + }, + { + description: "invalid distribution id", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "both backends", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagHTTP] = "true" + flagValues[flagBucket] = "true" + }, + ), + isValid: false, + }, + { + description: "max config without backend", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagRegions] = "EU,US" + flagValues[flagBlockedCountries] = "DE,AT,CH" + flagValues[flagBlockedIPs] = "127.0.0.1,10.0.0.8" + flagValues[flagDefaultCacheDuration] = "P1DT12H" + flagValues[flagLoki] = "true" + flagValues[flagLokiUsername] = "loki-user" + flagValues[flagLokiPushURL] = "https://loki.example.com" + flagValues[flagMonthlyLimitBytes] = fmt.Sprintf("%d", testMonthlyLimitBytes) + flagValues[flagOptimizer] = "true" + }, + ), + isValid: true, + expected: fixtureInputModel( + func(model *inputModel) { + model.Regions = []cdn.Region{cdn.REGION_EU, cdn.REGION_US} + model.BlockedCountries = []string{"DE", "AT", "CH"} + model.BlockedIPs = []string{"127.0.0.1", "10.0.0.8"} + model.DefaultCacheDuration = "P1DT12H" + model.Loki = &lokiInputModel{ + Username: "loki-user", + PushURL: "https://loki.example.com", + } + model.MonthlyLimitBytes = utils.Ptr(testMonthlyLimitBytes) + model.Optimizer = utils.Ptr(true) + }, + ), + }, + { + description: "max config http backend", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagHTTP] = "true" + flagValues[flagHTTPOriginURL] = "https://origin.example.com" + flagValues[flagHTTPOriginRequestHeaders] = "X-Example-Header: example-value, X-Another-Header: another-value" + flagValues[flagHTTPGeofencing] = "https://dach.example.com DE,AT,CH" + }, + ), + isValid: true, + expected: fixtureInputModel( + func(model *inputModel) { + model.HTTP = &httpInputModel{ + OriginURL: "https://origin.example.com", + OriginRequestHeaders: &map[string]string{ + "X-Example-Header": "example-value", + "X-Another-Header": "another-value", + }, + Geofencing: &map[string][]string{ + "https://dach.example.com": {"DE", "AT", "CH"}, + }, + } + }, + ), + }, + { + description: "max config bucket backend", + argValues: []string{testDistributionID}, + flagValues: fixtureFlagValues( + func(flagValues map[string]string) { + flagValues[flagBucket] = "true" + flagValues[flagBucketURL] = "https://bucket.example.com" + flagValues[flagBucketRegion] = "EU" + flagValues[flagBucketCredentialsAccessKeyID] = "access-key-id" + }, + ), + isValid: true, + expected: fixtureInputModel( + func(model *inputModel) { + model.Bucket = &bucketInputModel{ + URL: "https://bucket.example.com", + Region: "EU", + AccessKeyID: "access-key-id", + } + }, + ), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expected, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected cdn.ApiPatchDistributionRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expected: fixtureRequest(), + }, + { + description: "max without backend", + model: fixtureInputModel( + func(model *inputModel) { + model.Regions = []cdn.Region{cdn.REGION_EU, cdn.REGION_US} + model.BlockedCountries = []string{"DE", "AT", "CH"} + model.BlockedIPs = []string{"127.0.0.1", "10.0.0.8"} + model.DefaultCacheDuration = testCacheDuration + model.MonthlyLimitBytes = utils.Ptr(testMonthlyLimitBytes) + model.Loki = &lokiInputModel{ + Password: "loki-pass", + Username: "loki-user", + PushURL: "https://loki.example.com", + } + model.Optimizer = utils.Ptr(true) + }, + ), + expected: fixtureRequest( + func(payload *cdn.PatchDistributionPayload) { + payload.Config.Regions = &[]cdn.Region{cdn.REGION_EU, cdn.REGION_US} + payload.Config.BlockedCountries = &[]string{"DE", "AT", "CH"} + payload.Config.BlockedIps = &[]string{"127.0.0.1", "10.0.0.8"} + payload.Config.DefaultCacheDuration = cdn.NewNullableString(utils.Ptr(testCacheDuration)) + payload.Config.MonthlyLimitBytes = utils.Ptr(testMonthlyLimitBytes) + payload.Config.LogSink = cdn.NewNullableConfigPatchLogSink(&cdn.ConfigPatchLogSink{ + LokiLogSinkPatch: &cdn.LokiLogSinkPatch{ + Credentials: cdn.NewLokiLogSinkCredentials("loki-pass", "loki-user"), + PushUrl: utils.Ptr("https://loki.example.com"), + }, + }) + payload.Config.Optimizer = &cdn.OptimizerPatch{ + Enabled: utils.Ptr(true), + } + }, + ), + }, + { + description: "max http backend", + model: fixtureInputModel( + func(model *inputModel) { + model.HTTP = &httpInputModel{ + Geofencing: &map[string][]string{"https://dach.example.com": {"DE", "AT", "CH"}}, + OriginRequestHeaders: &map[string]string{"X-Example-Header": "example-value", "X-Another-Header": "another-value"}, + OriginURL: "https://http-backend.example.com", + } + }), + expected: fixtureRequest( + func(payload *cdn.PatchDistributionPayload) { + payload.Config.Backend = &cdn.ConfigPatchBackend{ + HttpBackendPatch: &cdn.HttpBackendPatch{ + Geofencing: &map[string][]string{"https://dach.example.com": {"DE", "AT", "CH"}}, + OriginRequestHeaders: &map[string]string{ + "X-Example-Header": "example-value", + "X-Another-Header": "another-value", + }, + OriginUrl: utils.Ptr("https://http-backend.example.com"), + Type: utils.Ptr("http"), + }, + } + }), + }, + { + description: "max bucket backend", + model: fixtureInputModel( + func(model *inputModel) { + model.Bucket = &bucketInputModel{ + URL: "https://bucket.example.com", + AccessKeyID: "bucket-access-key-id", + Password: "bucket-pass", + Region: "EU", + } + }), + expected: fixtureRequest( + func(payload *cdn.PatchDistributionPayload) { + payload.Config.Backend = &cdn.ConfigPatchBackend{ + BucketBackendPatch: &cdn.BucketBackendPatch{ + BucketUrl: utils.Ptr("https://bucket.example.com"), + Credentials: cdn.NewBucketCredentials("bucket-access-key-id", "bucket-pass"), + Region: utils.Ptr("EU"), + Type: utils.Ptr("bucket"), + }, + } + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient, tt.model) + + diff := cmp.Diff(request, tt.expected, + cmp.AllowUnexported(tt.expected, cdn.NullableString{}, cdn.NullableConfigPatchLogSink{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFormat string + response *cdn.PatchDistributionResponse + expected string + wantErr bool + }{ + { + description: "nil response", + outputFormat: "table", + response: nil, + wantErr: true, + }, + { + description: "table output", + outputFormat: "table", + response: &cdn.PatchDistributionResponse{ + Distribution: &cdn.Distribution{ + Id: ptr.To("dist-1234"), + }, + }, + expected: fmt.Sprintf("Updated CDN distribution for %q. ID: dist-1234\n", testProjectId), + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + buffer := &bytes.Buffer{} + p.Cmd.SetOut(buffer) + if err := outputResult(p, tt.outputFormat, testProjectId, tt.response); (err != nil) != tt.wantErr { + t.Fatalf("outputResult: %v", err) + } + if buffer.String() != tt.expected { + t.Errorf("want:\n%s\ngot:\n%s", tt.expected, buffer.String()) + } + }) + } +} diff --git a/internal/cmd/beta/edge/edge.go b/internal/cmd/beta/edge/edge.go new file mode 100644 index 000000000..35b5e0575 --- /dev/null +++ b/internal/cmd/beta/edge/edge.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package edge + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/kubeconfig" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/plans" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/token" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "edge-cloud", + Short: "Provides functionality for edge services.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) services.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(kubeconfig.NewCmd(params)) + cmd.AddCommand(token.NewCmd(params)) +} diff --git a/internal/cmd/beta/edge/instance/create/create.go b/internal/cmd/beta/edge/instance/create/create.go new file mode 100755 index 000000000..6faac9cc0 --- /dev/null +++ b/internal/cmd/beta/edge/instance/create/create.go @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates an edge instance", + Long: "Creates a STACKIT Edge Cloud (STEC) instance. The instance will take a moment to become fully functional.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Creates an edge instance with the %s "xxx" and %s "yyy"`, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance create --%s "xxx" --%s "yyy"`, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Prompt for confirmation + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a new edge instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + if resp == nil { + return fmt.Errorf("create instance: empty response from API") + } + if resp.Id == nil { + return fmt.Errorf("create instance: instance id missing in response") + } + instanceId := *resp.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating instance", func() error { + // The waiter handler needs a concrete concreteClient type. We can safely cast here as the real implementation will always match. + concreteClient, ok := apiClient.(*edge.APIClient) + if !ok { + return fmt.Errorf("failed to configure API concreteClient") + } + _, err = wait.CreateOrUpdateInstanceWaitHandler(ctx, concreteClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for edge instance creation: %w", err) + } + } + + // Handle output to printer + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +// inputModel represents the user input for creating an edge instance. +type inputModel struct { + *globalflags.GlobalFlagModel + DisplayName string + Description string + PlanId string +} + +// createRequestSpec captures the details of the request for testing. +type createRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + Payload edge.CreateInstancePayload + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.Instance, error) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + cmd.Flags().StringP(commonInstance.DescriptionFlag, commonInstance.DescriptionShorthand, "", commonInstance.DescriptionUsage) + cmd.Flags().String(commonInstance.PlanIdFlag, "", commonInstance.PlanIdUsage) + + cobra.CheckErr(flags.MarkFlagsRequired(cmd, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag)) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Parse and validate user input then add it to the model + displayNameValue := flags.FlagToStringPointer(p, cmd, commonInstance.DisplayNameFlag) + if err := commonInstance.ValidateDisplayName(displayNameValue); err != nil { + return nil, err + } + + planIdValue := flags.FlagToStringPointer(p, cmd, commonInstance.PlanIdFlag) + if err := commonInstance.ValidatePlanId(planIdValue); err != nil { + return nil, err + } + + descriptionValue := flags.FlagWithDefaultToStringValue(p, cmd, commonInstance.DescriptionFlag) + if err := commonInstance.ValidateDescription(descriptionValue); err != nil { + return nil, err + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: *displayNameValue, + Description: descriptionValue, + PlanId: *planIdValue, + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) (*edge.Instance, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + + return resp, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*createRequestSpec, error) { + req := apiClient.CreateInstance(ctx, model.ProjectId, model.Region) + + // Build request payload + payload := edge.CreateInstancePayload{ + DisplayName: &model.DisplayName, + Description: &model.Description, + PlanId: &model.PlanId, + } + req = req.CreateInstancePayload(payload) + + return &createRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Payload: payload, + Execute: req.Execute, + }, nil +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, instance *edge.Instance) error { + if instance == nil { + // This is only to prevent nil pointer deref. + // As long as the API behaves as defined by it's spec, instance can not be empty (HTTP 200 with an empty body) + return commonErr.NewNoInstanceError("") + } + + return p.OutputResult(outputFormat, instance, func() error { + operationState := "Created" + if async { + operationState = "Triggered creation of" + } + p.Outputf("%s instance for project %q. Instance ID: %q.\n", operationState, projectLabel, utils.PtrString(instance.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/edge/instance/create/create_test.go b/internal/cmd/beta/edge/instance/create/create_test.go new file mode 100755 index 000000000..7861a87c8 --- /dev/null +++ b/internal/cmd/beta/edge/instance/create/create_test.go @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + + testName = "test" + testPlanId = uuid.NewString() + testDescription = "Initial instance description" + testInstanceId = uuid.NewString() +) + +// mockExecutable is a mock for the Executable interface used by the SDK +type mockExecutable struct { + executeFails bool + resp *edge.Instance +} + +func (m *mockExecutable) CreateInstancePayload(_ edge.CreateInstancePayload) edge.ApiCreateInstanceRequest { + // This method is needed to satisfy the interface. It allows chaining in buildRequest. + return m +} +func (m *mockExecutable) Execute() (*edge.Instance, error) { + if m.executeFails { + return nil, errors.New("API error") + } + if m.resp != nil { + return m.resp, nil + } + return &edge.Instance{Id: &testInstanceId}, nil +} + +// mockAPIClient is a mock for the client.APIClient interface +type mockAPIClient struct { + createInstanceMock edge.ApiCreateInstanceRequest +} + +func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { + if m.createInstanceMock != nil { + return m.createInstanceMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the client.APIClient interface +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.DisplayNameFlag: testName, + commonInstance.DescriptionFlag: testDescription, + commonInstance.PlanIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: testName, + Description: testDescription, + PlanId: testPlanId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "create success", + want: fixtureInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}, globalflags.GlobalFlagModel{}), + }, + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "name missing", + wantErr: "required flag(s) \"name\" not set", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.DisplayNameFlag) + }), + }, + }, + { + name: "name too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = "this-name-is-way-too-long-for-the-validation" + }), + }, + }, + { + name: "name too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = "in" + }), + }, + }, + { + name: "name invalid", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = "1test" + }), + }, + }, + { + name: "plan invalid", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.PlanIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "description too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DescriptionFlag] = strings.Repeat("a", 257) + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + tests := []struct { + name string + args args + want *createRequestSpec + }{ + { + name: "success", + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + createInstanceMock: &mockExecutable{}, + }, + }, + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + Payload: edge.CreateInstancePayload{ + DisplayName: &testName, + Description: &testDescription, + PlanId: &testPlanId, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, _ := buildRequest(testCtx, tt.args.model, tt.args.client) + + if got != nil { + if got.Execute == nil { + t.Error("expected non-nil Execute function") + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(createRequestSpec{}, "Execute")) + } + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *edge.Instance + args args + }{ + { + name: "create success", + want: &edge.Instance{Id: &testInstanceId}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + createInstanceMock: &mockExecutable{ + resp: &edge.Instance{Id: &testInstanceId}, + }, + }, + }, + }, + { + name: "create API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + createInstanceMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + instance *edge.Instance + projectLabel string + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "no instance", + wantErr: &commonErr.NoInstanceError{}, + args: args{ + model: fixtureInputModel(), + }, + }, + { + name: "output json", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + instance: &edge.Instance{}, + }, + }, + { + name: "output yaml", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + }), + instance: &edge.Instance{}, + }, + }, + { + name: "output default", + args: args{ + model: fixtureInputModel(), + instance: &edge.Instance{Id: &testInstanceId}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.model.Async, tt.args.projectLabel, tt.args.instance) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} diff --git a/internal/cmd/beta/edge/instance/delete/delete.go b/internal/cmd/beta/edge/instance/delete/delete.go new file mode 100755 index 000000000..c6998e782 --- /dev/null +++ b/internal/cmd/beta/edge/instance/delete/delete.go @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier +} + +// deleteRequestSpec captures the details of a request for testing. +type deleteRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string // Set if deleting by ID + InstanceName string // Set if deleting by Name + + // Execute is a closure that wraps the actual SDK call + Execute func() error +} + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls and therefore different wait handlers. +// InstanceWaiter is an interface to abstract the different wait handlers so they can be used interchangeably. +type instanceWaiter interface { + WaitWithContext(context.Context) (*edge.Instance, error) +} + +// A function that creates an instance waiter +type instanceWaiterFactory = func(client *edge.APIClient) instanceWaiter + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Deletes an edge instance", + Long: "Deletes a STACKIT Edge Cloud (STEC) instance. The instance will be deleted permanently.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Delete an edge instance with %s "xxx"`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance delete --%s "xxx"`, commonInstance.InstanceIdFlag)), + examples.NewExample( + fmt.Sprintf(`Delete an edge instance with %s "xxx"`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance delete --%s "xxx"`, commonInstance.DisplayNameFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Prompt for confirmation + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the edge instance %q of project %q?", model.identifier.Value, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + err = run(ctx, model, apiClient) + if err != nil { + return err + } + + // Wait for async operation, if async mode not enabled + operationState := "Triggered deletion of" + if !model.Async { + err := spinner.Run(params.Printer, "Deleting instance", func() error { + // Determine identifier and waiter to use + waiterFactory, err := getWaiterFactory(ctx, model) + if err != nil { + return err + } + // The waiter factory needs a concrete concreteClient type. We can safely cast here as the real implementation will always match. + concreteClient, ok := apiClient.(*edge.APIClient) + if !ok { + return fmt.Errorf("failed to configure API concreteClient") + } + waiter := waiterFactory(concreteClient) + _, err = waiter.WaitWithContext(ctx) + return err + }) + + if err != nil { + return fmt.Errorf("wait for edge instance deletion: %w", err) + } + operationState = "Deleted" + } + + params.Printer.Info("%s instance with %q %q of project %q.\n", operationState, model.identifier.Flag, model.identifier.Value, projectLabel) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) error { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + if err := spec.Execute(); err != nil { + return cliErr.NewRequestFailedError(err) + } + + return nil +} + +// buildRequest constructs the spec that can be tested. +// It handles the logic of choosing between DeleteInstance and DeleteInstanceByName. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*deleteRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &deleteRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + } + + // Switch the concrete client based on the identifier flag used + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + req := apiClient.DeleteInstance(ctx, model.ProjectId, model.Region, model.identifier.Value) + spec.Execute = req.Execute + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + req := apiClient.DeleteInstanceByName(ctx, model.ProjectId, model.Region, model.identifier.Value) + spec.Execute = req.Execute + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + return spec, nil +} + +// Returns a factory function to create the appropriate waiter based on the input model. +func getWaiterFactory(ctx context.Context, model *inputModel) (instanceWaiterFactory, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + factory := func(c *edge.APIClient) instanceWaiter { + return wait.DeleteInstanceWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value) + } + return factory, nil + case commonInstance.DisplayNameFlag: + factory := func(c *edge.APIClient) instanceWaiter { + return wait.DeleteInstanceByNameWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value) + } + return factory, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} diff --git a/internal/cmd/beta/edge/instance/delete/delete_test.go b/internal/cmd/beta/edge/instance/delete/delete_test.go new file mode 100755 index 000000000..3e72a71d1 --- /dev/null +++ b/internal/cmd/beta/edge/instance/delete/delete_test.go @@ -0,0 +1,558 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package delete + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + + testInstanceId = "instance" + testDisplayName = "test" +) + +// mockExecutable implements the SDK delete request interface for testing. +type mockExecutable struct { + executeFails bool + executeNotFound bool +} + +func (m *mockExecutable) Execute() error { + if m.executeNotFound { + return &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + Body: []byte(`{"message":"not found"}`), + } + } + if m.executeFails { + return errors.New("execute failed") + } + return nil +} + +// mockAPIClient provides the minimal API client behavior required by the tests. +type mockAPIClient struct { + deleteInstanceMock edge.ApiDeleteInstanceRequest + deleteInstanceByNameMock edge.ApiDeleteInstanceByNameRequest +} + +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + if m.deleteInstanceMock != nil { + return m.deleteInstanceMock + } + return &mockExecutable{} +} + +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + if m.deleteInstanceByNameMock != nil { + return m.deleteInstanceByNameMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the client.APIClient interface. +func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.InstanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(useDisplayName bool, mods ...func(*inputModel)) *inputModel { + identifier := &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + if useDisplayName { + identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } + + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + identifier: identifier, + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureByIdInputModel(mods ...func(*inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(*inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}, globalflags.GlobalFlagModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}, globalflags.GlobalFlagModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "instance id empty", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "" + }), + }, + }, + { + name: "instance id too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" + }), + }, + }, + { + name: "instance id too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "id" + }), + }, + }, + { + name: "name too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foo" + }), + }, + }, + { + name: "name too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + tests := []struct { + name string + args args + wantErr error + }{ + { + name: "delete by id success", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + deleteInstanceMock: &mockExecutable{}, + }, + }, + }, + { + name: "delete by id API error", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + deleteInstanceMock: &mockExecutable{executeFails: true}, + }, + }, + wantErr: &cliErr.RequestFailedError{}, + }, + { + name: "delete by id not found", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + deleteInstanceMock: &mockExecutable{executeNotFound: true}, + }, + }, + wantErr: &cliErr.RequestFailedError{}, + }, + { + name: "delete by name success", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + deleteInstanceByNameMock: &mockExecutable{}, + }, + }, + }, + { + name: "delete by name API error", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + deleteInstanceByNameMock: &mockExecutable{executeFails: true}, + }, + }, + wantErr: &cliErr.RequestFailedError{}, + }, + { + name: "delete by name not found", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + deleteInstanceByNameMock: &mockExecutable{executeNotFound: true}, + }, + }, + wantErr: &cliErr.RequestFailedError{}, + }, + { + name: "no identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + wantErr: &commonErr.NoIdentifierError{}, + }, + { + name: "invalid identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = &commonValidation.Identifier{Flag: "unknown", Value: "value"} + }), + client: &mockAPIClient{}, + }, + wantErr: &cliErr.BuildRequestError{}, + }, + { + name: "nil model", + args: args{ + model: nil, + client: &mockAPIClient{}, + }, + wantErr: &commonErr.NoIdentifierError{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := run(testCtx, tt.args.model, tt.args.client) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + tests := []struct { + name string + args args + want *deleteRequestSpec + wantErr error + }{ + { + name: "by id", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + deleteInstanceMock: &mockExecutable{}, + }, + }, + want: &deleteRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + }, + }, + { + name: "by name", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + deleteInstanceByNameMock: &mockExecutable{}, + }, + }, + want: &deleteRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + }, + }, + { + name: "no identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + wantErr: &commonErr.NoIdentifierError{}, + }, + { + name: "invalid identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = &commonValidation.Identifier{Flag: "unknown", Value: "val"} + }), + client: &mockAPIClient{}, + }, + wantErr: &cliErr.BuildRequestError{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if got != nil { + if got.Execute == nil { + t.Error("expected non-nil Execute function") + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(deleteRequestSpec{}, "Execute")) + } + }) + } +} + +func TestGetWaiterFactory(t *testing.T) { + type args struct { + model *inputModel + } + + tests := []struct { + name string + wantErr error + want bool + args args + }{ + { + name: "by id identifier", + want: true, + args: args{ + model: fixtureByIdInputModel(), + }, + }, + { + name: "by name identifier", + want: true, + args: args{ + model: fixtureByNameInputModel(), + }, + }, + { + name: "nil model", + wantErr: &commonErr.NoIdentifierError{}, + want: false, + args: args{ + model: nil, + }, + }, + { + name: "nil identifier", + wantErr: &commonErr.NoIdentifierError{}, + want: false, + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = nil + }), + }, + }, + { + name: "invalid identifier", + wantErr: &commonErr.InvalidIdentifierError{}, + want: false, + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = &commonValidation.Identifier{Flag: "unsupported", Value: "value"} + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getWaiterFactory(testCtx, tt.args.model) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.want && got == nil { + t.Fatal("expected non-nil waiter factory") + } + if !tt.want && got != nil { + t.Fatal("expected nil waiter factory") + } + }) + } +} diff --git a/internal/cmd/beta/edge/instance/describe/describe.go b/internal/cmd/beta/edge/instance/describe/describe.go new file mode 100755 index 000000000..d4ac397b5 --- /dev/null +++ b/internal/cmd/beta/edge/instance/describe/describe.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier +} + +// describeRequestSpec captures the details of the request for testing. +type describeRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string // Set if describing by ID + InstanceName string // Set if describing by Name + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.Instance, error) +} + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describes an edge instance", + Long: "Describes a STACKIT Edge Cloud (STEC) instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Describe an edge instance with %s "xxx"`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance describe --%s `, commonInstance.InstanceIdFlag)), + examples.NewExample( + fmt.Sprintf(`Describe an edge instance with %s "xxx"`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance describe --%s `, commonInstance.DisplayNameFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + // Handle output to printer + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) (*edge.Instance, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + + return resp, nil +} + +// buildRequest constructs the spec that can be tested. +// It handles the logic of choosing between GetInstance and GetInstanceByName. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*describeRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &describeRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + } + + // Switch the concrete client based on the identifier flag used + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + req := apiClient.GetInstance(ctx, model.ProjectId, model.Region, model.identifier.Value) + spec.Execute = req.Execute + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + req := apiClient.GetInstanceByName(ctx, model.ProjectId, model.Region, model.identifier.Value) + spec.Execute = req.Execute + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + return spec, nil +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat string, instance *edge.Instance) error { + if instance == nil { + // This is only to prevent nil pointer deref. + // As long as the API behaves as defined by it's spec, instance can not be empty (HTTP 200 with an empty body) + return commonErr.NewNoInstanceError("") + } + + return p.OutputResult(outputFormat, instance, func() error { + table := tables.NewTable() + // Describe: output all fields. Be sure to filter for any non-required fields. + table.AddRow("CREATED", utils.PtrString(instance.Created)) + table.AddSeparator() + table.AddRow("ID", utils.PtrString(instance.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(instance.DisplayName)) + table.AddSeparator() + if instance.HasDescription() { + table.AddRow("DESCRIPTION", utils.PtrString(instance.Description)) + table.AddSeparator() + } + table.AddRow("UI", utils.PtrString(instance.FrontendUrl)) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(instance.Status)) + table.AddSeparator() + table.AddRow("PLAN", utils.PtrString(instance.PlanId)) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/edge/instance/describe/describe_test.go b/internal/cmd/beta/edge/instance/describe/describe_test.go new file mode 100755 index 000000000..a660532f9 --- /dev/null +++ b/internal/cmd/beta/edge/instance/describe/describe_test.go @@ -0,0 +1,576 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package describe + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + + testInstanceId = "instance" + testDisplayName = "test" +) + +// mockExecutable is a mock for the Executable interface +type mockExecutable struct { + executeFails bool + executeNotFound bool + executeResp *edge.Instance +} + +func (m *mockExecutable) Execute() (*edge.Instance, error) { + if m.executeFails { + return nil, errors.New("API error") + } + if m.executeNotFound { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + } + return m.executeResp, nil +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct { + getInstanceMock edge.ApiGetInstanceRequest + getInstanceByNameMock edge.ApiGetInstanceByNameRequest +} + +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + if m.getInstanceMock != nil { + return m.getInstanceMock + } + return &mockExecutable{} +} + +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + if m.getInstanceByNameMock != nil { + return m.getInstanceByNameMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { + return nil +} +func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + commonInstance.InstanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureByIdInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + + if useName { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } else { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "instanceId missing", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "instanceId empty", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "" + }), + }, + }, + { + name: "instanceId too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" + }), + }, + }, + { + name: "instanceId too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "id" + }), + }, + }, + { + name: "name too short", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foo" + }), + }, + }, + { + name: "name too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *edge.Instance + args args + }{ + { + name: "get by id success", + want: &edge.Instance{ + Id: &testInstanceId, + DisplayName: &testDisplayName, + }, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + getInstanceMock: &mockExecutable{ + executeResp: &edge.Instance{ + Id: &testInstanceId, + DisplayName: &testDisplayName, + }, + }, + }, + }, + }, + { + name: "get by name success", + want: &edge.Instance{ + Id: &testInstanceId, + DisplayName: &testDisplayName, + }, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + getInstanceByNameMock: &mockExecutable{ + executeResp: &edge.Instance{ + Id: &testInstanceId, + DisplayName: &testDisplayName, + }, + }, + }, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "instance not found error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + getInstanceMock: &mockExecutable{ + executeNotFound: true, + }, + }, + }, + }, + { + name: "get by id API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + getInstanceMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + { + name: "get by name API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + getInstanceByNameMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestOutputResult(t *testing.T) { + type outputArgs struct { + model *inputModel + instance *edge.Instance + } + + tests := []struct { + name string + wantErr error + args outputArgs + }{ + { + name: "no instance", + wantErr: &commonErr.NoInstanceError{}, + args: outputArgs{ + model: fixtureByIdInputModel(), + instance: nil, + }, + }, + { + name: "output json", + args: outputArgs{ + model: fixtureInputModel(false, func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + model.identifier = nil + }), + instance: &edge.Instance{}, + }, + }, + { + name: "output yaml", + args: outputArgs{ + model: fixtureInputModel(false, func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + model.identifier = nil + }), + instance: &edge.Instance{}, + }, + }, + { + name: "output default", + args: outputArgs{ + model: fixtureByIdInputModel(), + instance: &edge.Instance{Id: &testInstanceId}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.instance) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *describeRequestSpec + args args + }{ + { + name: "get by id", + want: &describeRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + }, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + getInstanceMock: &mockExecutable{}, + }, + }, + }, + { + name: "get by name", + want: &describeRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + }, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + getInstanceByNameMock: &mockExecutable{}, + }, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(describeRequestSpec{}, "Execute")) + }) + } +} diff --git a/internal/cmd/beta/edge/instance/instance.go b/internal/cmd/beta/edge/instance/instance.go new file mode 100644 index 000000000..748371cda --- /dev/null +++ b/internal/cmd/beta/edge/instance/instance.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/instance/update" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for edge instances.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) instance management.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) +} diff --git a/internal/cmd/beta/edge/instance/list/list.go b/internal/cmd/beta/edge/instance/list/list.go new file mode 100755 index 000000000..3b728ee2d --- /dev/null +++ b/internal/cmd/beta/edge/instance/list/list.go @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +// listRequestSpec captures the details of the request for testing. +type listRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + Limit *int64 + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.InstanceList, error) +} + +// Command constructor +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists edge instances", + Long: "Lists STACKIT Edge Cloud (STEC) instances of a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all edge instances of a given project`, + `$ stackit beta edge-cloud instance list`), + examples.NewExample( + `Lists all edge instances of a given project and limits the output to two instances`, + fmt.Sprintf(`$ stackit beta edge-cloud instance list --%s 2`, limitFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Parse and validate user input then add it to the model + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) ([]edge.Instance, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + if resp == nil { + return nil, fmt.Errorf("list instances: empty response from API") + } + if resp.Instances == nil { + return nil, fmt.Errorf("list instances: instances missing in response") + } + instances := *resp.Instances + + // Truncate output if limit is set + if spec.Limit != nil && len(instances) > int(*spec.Limit) { + instances = instances[:*spec.Limit] + } + + return instances, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*listRequestSpec, error) { + req := apiClient.ListInstances(ctx, model.ProjectId, model.Region) + + return &listRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Limit: model.Limit, + Execute: req.Execute, + }, nil +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []edge.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + // No instances found for project + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil + } + + // Display instances found for project in a table + table := tables.NewTable() + // List: only output the most important fields. Be sure to filter for any non-required fields. + table.SetHeader("ID", "NAME", "UI", "STATE") + for i := range instances { + instance := instances[i] + table.AddRow( + utils.PtrString(instance.Id), + utils.PtrString(instance.DisplayName), + utils.PtrString(instance.FrontendUrl), + utils.PtrString(instance.Status)) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/edge/instance/list/list_test.go b/internal/cmd/beta/edge/instance/list/list_test.go new file mode 100755 index 000000000..cf346b7fb --- /dev/null +++ b/internal/cmd/beta/edge/instance/list/list_test.go @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package list + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" +) + +// mockExecutable is a mock for the Executable interface +type mockExecutable struct { + executeFails bool + executeResp *edge.InstanceList +} + +func (m *mockExecutable) Execute() (*edge.InstanceList, error) { + if m.executeFails { + return nil, errors.New("API error") + } + + if m.executeResp != nil { + return m.executeResp, nil + } + return &edge.InstanceList{ + Instances: &[]edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + {Id: utils.Ptr("instance-2"), DisplayName: utils.Ptr("nameb")}, + }, + }, nil +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct { + listInstancesMock edge.ApiListInstancesRequest +} + +func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { + if m.listInstancesMock != nil { + return m.listInstancesMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "success", + want: fixtureInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "with limit", + want: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(10)) + }), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "10" + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "limit invalid", + wantErr: "invalid syntax", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + }, + }, + { + name: "limit less than 1", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want []edge.Instance + args args + }{ + { + name: "list success", + want: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + {Id: utils.Ptr("instance-2"), DisplayName: utils.Ptr("nameb")}, + }, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with limit", + want: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(1)) + }), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with limit greater than items", + want: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + {Id: utils.Ptr("instance-2"), DisplayName: utils.Ptr("nameb")}, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(5)) + }), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with no items", + want: []edge.Instance{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + listInstancesMock: &mockExecutable{ + executeResp: &edge.InstanceList{Instances: &[]edge.Instance{}}, + }, + }, + }, + }, + { + name: "list API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + listInstancesMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + instances []edge.Instance + projectLabel string + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "no instance", + args: args{ + model: fixtureInputModel(), + }, + }, + { + name: "output json", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + instances: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + }, + projectLabel: "test-project", + }, + }, + { + name: "output yaml", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + }), + instances: []edge.Instance{ + {Id: utils.Ptr("instance-1"), DisplayName: utils.Ptr("namea")}, + }, + projectLabel: "test-project", + }, + }, + { + name: "output default with instances", + args: args{ + model: fixtureInputModel(), + instances: []edge.Instance{ + { + Id: utils.Ptr("instance-1"), + DisplayName: utils.Ptr("namea"), + FrontendUrl: utils.Ptr("https://example.com"), + }, + { + Id: utils.Ptr("instance-2"), + DisplayName: utils.Ptr("nameb"), + FrontendUrl: utils.Ptr("https://example2.com"), + }, + }, + projectLabel: "test-project", + }, + }, + { + name: "output default with no instances", + args: args{ + model: fixtureInputModel(), + instances: []edge.Instance{}, + projectLabel: "test-project", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.projectLabel, tt.args.instances) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *listRequestSpec + args args + }{ + { + name: "success", + want: &listRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + }, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + listInstancesMock: &mockExecutable{}, + }, + }, + }, + { + name: "success with limit", + want: &listRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + Limit: utils.Ptr(int64(10)), + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(10)) + }), + client: &mockAPIClient{ + listInstancesMock: &mockExecutable{}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(listRequestSpec{}, "Execute")) + }) + } +} diff --git a/internal/cmd/beta/edge/instance/update/update.go b/internal/cmd/beta/edge/instance/update/update.go new file mode 100755 index 000000000..2a6d3f6dd --- /dev/null +++ b/internal/cmd/beta/edge/instance/update/update.go @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier + Description *string + PlanId *string +} + +// updateRequestSpec captures the details of the request for testing. +type updateRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string // Set if updating by ID + InstanceName string // Set if updating by Name + Payload edge.UpdateInstancePayload + + // Execute is a closure that wraps the actual SDK call + Execute func() error +} + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls and therefore different wait handlers. +// InstanceWaiter is an interface to abstract the different wait handlers so they can be used interchangeably. +type instanceWaiter interface { + WaitWithContext(context.Context) (*edge.Instance, error) +} + +// A function that creates an instance waiter +type instanceWaiterFactory = func(client *edge.APIClient) instanceWaiter + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates an edge instance", + Long: "Updates a STACKIT Edge Cloud (STEC) instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Updates the description of an edge instance with %s "xxx"`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance update --%s "xxx" --%s "yyy"`, commonInstance.InstanceIdFlag, commonInstance.DescriptionFlag)), + examples.NewExample( + fmt.Sprintf(`Updates the plan of an edge instance with %s "xxx"`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance update --%s "xxx" --%s "yyy"`, commonInstance.DisplayNameFlag, commonInstance.PlanIdFlag)), + examples.NewExample( + fmt.Sprintf(`Updates the description and plan of an edge instance with %s "xxx"`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud instance update --%s "xxx" --%s "yyy" --%s "zzz"`, commonInstance.InstanceIdFlag, commonInstance.DescriptionFlag, commonInstance.PlanIdFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Prompt for confirmation + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update the edge instance %q of project %q?", model.identifier.Value, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + err = run(ctx, model, apiClient) + if err != nil { + return err + } + + // Wait for async operation, if async mode not enabled + operationState := "Triggered update of" + if !model.Async { + // Wait for async operation, if async mode not enabled + // Show spinner while waiting + err := spinner.Run(params.Printer, "Updating instance", func() error { + // Determine identifier and waiter to use + waiterFactory, err := getWaiterFactory(ctx, model) + if err != nil { + return err + } + // The waiter handler needs a concrete concreteClient type. We can safely cast here as the real implementation will always match. + concreteClient, ok := apiClient.(*edge.APIClient) + if !ok { + return fmt.Errorf("failed to configure API concreteClient") + } + waiter := waiterFactory(concreteClient) + _, err = waiter.WaitWithContext(ctx) + return err + }) + + if err != nil { + return fmt.Errorf("wait for edge instance update: %w", err) + } + operationState = "Updated" + } + + params.Printer.Info("%s instance with %q %q of project %q.\n", operationState, model.identifier.Flag, model.identifier.Value, projectLabel) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + cmd.Flags().StringP(commonInstance.DescriptionFlag, commonInstance.DescriptionShorthand, "", commonInstance.DescriptionUsage) + cmd.Flags().StringP(commonInstance.PlanIdFlag, "", "", commonInstance.PlanIdUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) + + // Make sure at least one updatable field is provided, otherwise it would be a no-op + updatedFields := []string{commonInstance.DescriptionFlag, commonInstance.PlanIdFlag} + cmd.MarkFlagsOneRequired(updatedFields...) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + if planIdValue := flags.FlagToStringPointer(p, cmd, commonInstance.PlanIdFlag); planIdValue != nil { + if err := commonInstance.ValidatePlanId(planIdValue); err != nil { + return nil, err + } + model.PlanId = planIdValue + } + + if descriptionValue := flags.FlagToStringPointer(p, cmd, commonInstance.DescriptionFlag); descriptionValue != nil { + if err := commonInstance.ValidateDescription(*descriptionValue); err != nil { + return nil, err + } + model.Description = descriptionValue + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) error { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + err = spec.Execute() + if err != nil { + return cliErr.NewRequestFailedError(err) + } + + return nil +} + +// buildRequest constructs the spec that can be tested. +// It handles the logic of choosing between UpdateInstance and UpdateInstanceByName. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*updateRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &updateRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Payload: edge.UpdateInstancePayload{ + Description: model.Description, + PlanId: model.PlanId, + }, + } + + // Switch the concrete client based on the identifier flag used + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + req := apiClient.UpdateInstance(ctx, model.ProjectId, model.Region, model.identifier.Value) + req = req.UpdateInstancePayload(spec.Payload) + spec.Execute = req.Execute + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + req := apiClient.UpdateInstanceByName(ctx, model.ProjectId, model.Region, model.identifier.Value) + req = req.UpdateInstanceByNamePayload(edge.UpdateInstanceByNamePayload{ + Description: spec.Payload.Description, + PlanId: spec.Payload.PlanId, + }) + spec.Execute = req.Execute + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + return spec, nil +} + +// Returns a factory function to create the appropriate waiter based on the input model. +func getWaiterFactory(ctx context.Context, model *inputModel) (instanceWaiterFactory, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + factory := func(c *edge.APIClient) instanceWaiter { + return wait.CreateOrUpdateInstanceWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value) + } + return factory, nil + case commonInstance.DisplayNameFlag: + factory := func(c *edge.APIClient) instanceWaiter { + return wait.CreateOrUpdateInstanceByNameWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value) + } + return factory, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} diff --git a/internal/cmd/beta/edge/instance/update/update_test.go b/internal/cmd/beta/edge/instance/update/update_test.go new file mode 100755 index 000000000..c5e3e92a8 --- /dev/null +++ b/internal/cmd/beta/edge/instance/update/update_test.go @@ -0,0 +1,542 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package update + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + testInstanceId = "instance" + testDisplayName = "test" + testDescription = "new description" + testPlanId = uuid.NewString() +) + +type mockExecutable struct { + executeFails bool + executeNotFound bool + capturedUpdatePayload *edge.UpdateInstancePayload + capturedUpdateByNamePayload *edge.UpdateInstanceByNamePayload +} + +func (m *mockExecutable) Execute() error { + if m.executeFails { + return errors.New("API error") + } + if m.executeNotFound { + return &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + } + return nil +} + +func (m *mockExecutable) UpdateInstancePayload(payload edge.UpdateInstancePayload) edge.ApiUpdateInstanceRequest { + if m.capturedUpdatePayload != nil { + *m.capturedUpdatePayload = payload + } + return m +} + +func (m *mockExecutable) UpdateInstanceByNamePayload(payload edge.UpdateInstanceByNamePayload) edge.ApiUpdateInstanceByNameRequest { + if m.capturedUpdateByNamePayload != nil { + *m.capturedUpdateByNamePayload = payload + } + return m +} + +type mockAPIClient struct { + updateInstanceMock edge.ApiUpdateInstanceRequest + updateInstanceByNameMock edge.ApiUpdateInstanceByNameRequest +} + +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + if m.updateInstanceMock != nil { + return m.updateInstanceMock + } + return &mockExecutable{} +} + +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + if m.updateInstanceByNameMock != nil { + return m.updateInstanceByNameMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.InstanceIdFlag: testInstanceId, + commonInstance.DescriptionFlag: testDescription, + commonInstance.PlanIdFlag: testPlanId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureByIdInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Description: &testDescription, + PlanId: &testPlanId, + } + + if useName { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } else { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "no update flags", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.DescriptionFlag) + delete(flagValues, commonInstance.PlanIdFlag) + }), + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "plan id invalid", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.PlanIdFlag] = "not-a-uuid" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + tests := []struct { + name string + args args + want *updateRequestSpec + wantErr error + }{ + { + name: "by id", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + updateInstanceMock: &mockExecutable{}, + }, + }, + want: &updateRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + Payload: edge.UpdateInstancePayload{ + Description: &testDescription, + PlanId: &testPlanId, + }, + }, + }, + { + name: "by name", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + updateInstanceByNameMock: &mockExecutable{}, + }, + }, + want: &updateRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + Payload: edge.UpdateInstancePayload{ + Description: &testDescription, + PlanId: &testPlanId, + }, + }, + }, + { + name: "no identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + wantErr: &commonErr.NoIdentifierError{}, + }, + { + name: "invalid identifier", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.identifier = &commonValidation.Identifier{Flag: "unknown", Value: "val"} + }), + client: &mockAPIClient{}, + }, + wantErr: &cliErr.BuildRequestError{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if got != nil { + if got.Execute == nil { + t.Error("expected non-nil Execute function") + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(updateRequestSpec{}, "Execute")) + } + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "update by id success", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + updateInstanceMock: &mockExecutable{}, + }, + }, + }, + { + name: "update by name success", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + updateInstanceByNameMock: &mockExecutable{}, + }, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "instance not found error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + updateInstanceMock: &mockExecutable{ + executeNotFound: true, + }, + }, + }, + }, + { + name: "update by id API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{ + updateInstanceMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + { + name: "update by name API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{ + updateInstanceByNameMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := run(testCtx, tt.args.model, tt.args.client) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestGetWaiterFactory(t *testing.T) { + type args struct { + model *inputModel + } + + tests := []struct { + name string + wantErr error + want bool + args args + }{ + { + name: "by id", + want: true, + args: args{ + model: fixtureByIdInputModel(), + }, + }, + { + name: "by name", + want: true, + args: args{ + model: fixtureByNameInputModel(), + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + want: false, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + }, + }, + { + name: "unknown identifier", + wantErr: &commonErr.InvalidIdentifierError{}, + want: false, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier.Flag = "unknown" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getWaiterFactory(testCtx, tt.args.model) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.want && got == nil { + t.Fatal("expected non-nil waiter factory") + } + if !tt.want && got != nil { + t.Fatal("expected nil waiter factory") + } + }) + } +} diff --git a/internal/cmd/beta/edge/kubeconfig/create/create.go b/internal/cmd/beta/edge/kubeconfig/create/create.go new file mode 100755 index 000000000..dd38b8322 --- /dev/null +++ b/internal/cmd/beta/edge/kubeconfig/create/create.go @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonKubeconfig "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/kubeconfig" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier + DisableWriting bool + Filepath *string + Overwrite bool + Expiration uint64 + SwitchContext bool +} + +// createRequestSpec captures the details of the request for testing. +type createRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string + InstanceName string + Expiration int64 + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.Kubeconfig, error) +} + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls and therefore different wait handlers. +// KubeconfigWaiter is an interface to abstract the different wait handlers so they can be used interchangeably. +type kubeconfigWaiter interface { + WaitWithContext(context.Context) (*edge.Kubeconfig, error) +} + +// A function that creates a kubeconfig waiter +type kubeconfigWaiterFactory = func(client *edge.APIClient) kubeconfigWaiter + +// waiterFactoryProvider is an interface that provides kubeconfig waiters so we can inject different impl. while testing. +type waiterFactoryProvider interface { + getKubeconfigWaiter(ctx context.Context, model *inputModel, apiClient client.APIClient) (kubeconfigWaiter, error) +} + +// productionWaiterFactoryProvider is the real implementation used in production. +// It handles the concrete client type casting required by the SDK's wait handlers. +type productionWaiterFactoryProvider struct{} + +func (p *productionWaiterFactoryProvider) getKubeconfigWaiter(ctx context.Context, model *inputModel, apiClient client.APIClient) (kubeconfigWaiter, error) { + waiterFactory, err := getWaiterFactory(ctx, model) + if err != nil { + return nil, err + } + // The waiter handler needs a concrete client type. We can safely cast here as the real implementation will always match. + edgeClient, ok := apiClient.(*edge.APIClient) + if !ok { + return nil, cliErr.NewBuildRequestError("failed to configure API client", nil) + } + return waiterFactory(edgeClient), nil +} + +// waiterProvider is the package-level variable used to get the waiter. +// It is initialized with the production implementation but can be overridden in tests. +var waiterProvider waiterFactoryProvider = &productionWaiterFactoryProvider{} + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates or updates a local kubeconfig file of an edge instance", + Long: fmt.Sprintf("%s\n\n%s\n%s\n%s\n%s", + "Creates or updates a local kubeconfig file of a STACKIT Edge Cloud (STEC) instance. If the config exists in the kubeconfig file, the information will be updated.", + "By default, the kubeconfig information of the edge instance is merged into the current kubeconfig file which is determined by Kubernetes client logic. If the kubeconfig file doesn't exist, a new one will be created.", + fmt.Sprintf("You can override this behavior by specifying a custom filepath with the --%s flag or disable writing with the --%s flag.", commonKubeconfig.FilepathFlag, commonKubeconfig.DisableWritingFlag), + fmt.Sprintf("An expiration time can be set for the kubeconfig. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is %d seconds.", commonKubeconfig.ExpirationSecondsDefault), + "Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units."), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Create or update a kubeconfig for the edge instance with %s "xxx". If the config exists in the kubeconfig file, the information will be updated.`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud kubeconfig create --%s "xxx"`, commonInstance.InstanceIdFlag)), + examples.NewExample( + fmt.Sprintf(`Create or update a kubeconfig for the edge instance with %s "xxx" in a custom filepath.`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud kubeconfig create --%s "xxx" --filepath "yyy"`, commonInstance.DisplayNameFlag)), + examples.NewExample( + fmt.Sprintf(`Get a kubeconfig for the edge instance with %s "xxx" without writing it to a file and format the output as json.`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud kubeconfig create --%s "xxx" --disable-writing --output-format json`, commonInstance.DisplayNameFlag)), + examples.NewExample( + fmt.Sprintf(`Create a kubeconfig for the edge instance with %s "xxx". This will replace your current kubeconfig file.`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud kubeconfig create --%s "xxx" --overwrite`, commonInstance.InstanceIdFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Prompt for confirmation is handled in outputResult + + if model.Async { + return fmt.Errorf("async mode is not supported for kubeconfig create") + } + + // Call API via waiter (which handles both the API call and waiting) + kubeconfig, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + // Handle file operations or output to printer + return outputResult(params.Printer, model.OutputFormat, model, kubeconfig) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + cmd.Flags().Bool(commonKubeconfig.DisableWritingFlag, false, commonKubeconfig.DisableWritingUsage) + cmd.Flags().StringP(commonKubeconfig.FilepathFlag, commonKubeconfig.FilepathShorthand, "", commonKubeconfig.FilepathUsage) + cmd.Flags().StringP(commonKubeconfig.ExpirationFlag, commonKubeconfig.ExpirationShorthand, "", commonKubeconfig.ExpirationUsage) + cmd.Flags().Bool(commonKubeconfig.OverwriteFlag, false, commonKubeconfig.OverwriteUsage) + cmd.Flags().Bool(commonKubeconfig.SwitchContextFlag, false, commonKubeconfig.SwitchContextUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) + cmd.MarkFlagsMutuallyExclusive(commonKubeconfig.DisableWritingFlag, commonKubeconfig.FilepathFlag) // DisableWriting xor Filepath + cmd.MarkFlagsMutuallyExclusive(commonKubeconfig.DisableWritingFlag, commonKubeconfig.OverwriteFlag) // DisableWriting xor Overwrite +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + Filepath: flags.FlagToStringPointer(p, cmd, commonKubeconfig.FilepathFlag), + Overwrite: flags.FlagToBoolValue(p, cmd, commonKubeconfig.OverwriteFlag), + SwitchContext: flags.FlagToBoolValue(p, cmd, commonKubeconfig.SwitchContextFlag), + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + // Parse and validate kubeconfig expiration time + if expString := flags.FlagToStringPointer(p, cmd, commonKubeconfig.ExpirationFlag); expString != nil { + expTime, err := utils.ConvertToSeconds(*expString) + if err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.ExpirationFlag, + Details: err.Error(), + } + } + if err := commonKubeconfig.ValidateExpiration(&expTime); err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.ExpirationFlag, + Details: err.Error(), + } + } + model.Expiration = expTime + } else { + // Default expiration is 1 hour + defaultExp := uint64(commonKubeconfig.ExpirationSecondsDefault) + model.Expiration = defaultExp + } + + disableWriting := flags.FlagToBoolValue(p, cmd, commonKubeconfig.DisableWritingFlag) + model.DisableWriting = disableWriting + // Make sure to only output if the format is explicitly set + if disableWriting { + if globalFlags.OutputFormat == "" || globalFlags.OutputFormat == print.NoneOutputFormat { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.DisableWritingFlag, + Details: fmt.Sprintf("must be used with --%s", globalflags.OutputFormatFlag), + } + } + if globalFlags.OutputFormat != print.JSONOutputFormat && globalFlags.OutputFormat != print.YAMLOutputFormat { + return nil, &cliErr.FlagValidationError{ + Flag: globalflags.OutputFormatFlag, + Details: fmt.Sprintf("valid output formats for this command are: %s", fmt.Sprintf("%s, %s", print.JSONOutputFormat, print.YAMLOutputFormat)), + } + } + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) (*edge.Kubeconfig, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + + return resp, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*createRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &createRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Expiration: int64(model.Expiration), // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + } + + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + // Closure used to decouple the actual SDK call for easier testing + spec.Execute = func() (*edge.Kubeconfig, error) { + // Get the waiter from the provider (handles client type casting internally) + waiter, err := waiterProvider.getKubeconfigWaiter(ctx, model, apiClient) + if err != nil { + return nil, err + } + + return waiter.WaitWithContext(ctx) + } + + return spec, nil +} + +// Returns a factory function to create the appropriate waiter based on the input model. +func getWaiterFactory(ctx context.Context, model *inputModel) (kubeconfigWaiterFactory, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + // The KubeconfigWaitHandlers don't wait for the kubeconfig to be created, but for the instance to be ready to return a kubeconfig. + // Convert uint64 to int64 to match the API's type. + var expiration = int64(model.Expiration) // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + factory := func(c *edge.APIClient) kubeconfigWaiter { + return wait.KubeconfigWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value, &expiration) + } + return factory, nil + case commonInstance.DisplayNameFlag: + factory := func(c *edge.APIClient) kubeconfigWaiter { + return wait.KubeconfigByInstanceNameWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value, &expiration) + } + return factory, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat string, model *inputModel, kubeconfig *edge.Kubeconfig) error { + // Ensure kubeconfig data is present + if kubeconfig == nil || kubeconfig.Kubeconfig == nil { + return fmt.Errorf("no kubeconfig returned from the API") + } + kubeconfigMap := *kubeconfig.Kubeconfig + + // Determine output format for terminal or file output + var format string + switch outputFormat { + case print.JSONOutputFormat: + // JSON if explicitly requested + format = print.JSONOutputFormat + case print.YAMLOutputFormat: + // YAML if explicitly requested + format = print.YAMLOutputFormat + default: + if model.DisableWriting { + // If not explicitly requested, use JSON as default for terminal output + format = print.JSONOutputFormat + } else { + // If not explicitly requested, use YAML as default for file output + format = print.YAMLOutputFormat + } + } + + // Marshal kubeconfig data based on the determined format + kubeconfigData, err := marshalKubeconfig(kubeconfigMap, format) + if err != nil { + return err + } + + // Handle file writing and output + if !model.DisableWriting { + // Build options for writing kubeconfig + opts := commonKubeconfig.NewWriteOptions(). + WithOverwrite(model.Overwrite). + WithSwitchContext(model.SwitchContext) + + // Add confirmation callback if not assumeYes + if !model.AssumeYes { + confirmFn := func(message string) error { + return p.PromptForConfirmation(message) + } + opts = opts.WithConfirmation(confirmFn) + } + + path, err := commonKubeconfig.WriteKubeconfig(model.Filepath, kubeconfigData, opts) + if err != nil { + return err + } + + // Inform the user about the successful write operation + p.Outputf("Wrote kubeconfig for instance %q to %q.\n", model.identifier.Value, *path) + + if model.SwitchContext { + p.Outputln("Switched context as requested.") + } + } else { + p.Outputln(kubeconfigData) + } + return nil +} + +// Marshal kubeconfig data to the specified format +func marshalKubeconfig(kubeconfigMap map[string]interface{}, format string) (string, error) { + switch format { + case print.JSONOutputFormat: + kubeconfigJSON, err := json.MarshalIndent(kubeconfigMap, "", " ") + if err != nil { + return "", fmt.Errorf("marshal kubeconfig to JSON: %w", err) + } + return string(kubeconfigJSON), nil + case print.YAMLOutputFormat: + kubeconfigYAML, err := yaml.MarshalWithOptions(kubeconfigMap, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return "", fmt.Errorf("marshal kubeconfig to YAML: %w", err) + } + return string(kubeconfigYAML), nil + default: + return "", fmt.Errorf("%w: %s", commonErr.NewNoIdentifierError(""), format) + } +} diff --git a/internal/cmd/beta/edge/kubeconfig/create/create_test.go b/internal/cmd/beta/edge/kubeconfig/create/create_test.go new file mode 100755 index 000000000..65ef09fda --- /dev/null +++ b/internal/cmd/beta/edge/kubeconfig/create/create_test.go @@ -0,0 +1,821 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/goccy/go-yaml" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonKubeconfig "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/kubeconfig" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + testInstanceId = "instance" + testDisplayName = "test" + testExpiration = "1h" +) + +const ( + testKubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-1.com + name: cluster-1 +contexts: +- context: + cluster: cluster-1 + user: user-1 + name: context-1 +current-context: context-1 +kind: Config +preferences: {} +users: +- name: user-1 + user: {} +` +) + +// Helper function to create a new instance of Kubeconfig +// +//nolint:gocritic // ptrToRefParam: Required by edge.Kubeconfig API which expects *map[string]interface{} +func testKubeconfigMap() *map[string]interface{} { + var kubeconfigMap map[string]interface{} + err := yaml.Unmarshal([]byte(testKubeconfig), &kubeconfigMap) + if err != nil { + // This should never happen in tests with valid YAML + panic(err) + } + return utils.Ptr(kubeconfigMap) +} + +// mockKubeconfigWaiter is a mock for the kubeconfigWaiter interface +type mockKubeconfigWaiter struct { + waitFails bool + waitNotFound bool + waitResp *edge.Kubeconfig +} + +func (m *mockKubeconfigWaiter) WaitWithContext(_ context.Context) (*edge.Kubeconfig, error) { + if m.waitFails { + return nil, errors.New("wait error") + } + if m.waitNotFound { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + } + if m.waitResp != nil { + return m.waitResp, nil + } + + // Default kubeconfig response + return &edge.Kubeconfig{ + Kubeconfig: testKubeconfigMap(), + }, nil +} + +// testWaiterFactoryProvider is a test implementation that returns mock waiters. +type testWaiterFactoryProvider struct { + waiter kubeconfigWaiter +} + +func (t *testWaiterFactoryProvider) getKubeconfigWaiter(_ context.Context, model *inputModel, _ client.APIClient) (kubeconfigWaiter, error) { + if model == nil || model.identifier == nil { + return nil, &commonErr.NoIdentifierError{} + } + + // Validate identifier like the real implementation + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag: + // Return our mock waiter directly, bypassing the client type casting issue + return t.waiter, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct{} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} + +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} + +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { + return nil +} + +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} + +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} + +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} + +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} + +func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { + return nil +} + +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} + +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.InstanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureByIdInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisableWriting: false, + Filepath: nil, + Overwrite: false, + Expiration: uint64(3600), // Default 1 hour + SwitchContext: false, + } + + if useName { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } else { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "with expiration", + want: fixtureByIdInputModel(func(model *inputModel) { + model.Expiration = uint64(3600) + }), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = testExpiration + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "instance id missing", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + }), + }, + }, + { + name: "instance id empty", + wantErr: "id may not be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "" + }), + }, + }, + { + name: "instance id too long", + wantErr: "id is too long", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" + }), + }, + }, + { + name: "instance id too short", + wantErr: "id is too short", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "id" + }), + }, + }, + { + name: "name too short", + wantErr: "name is too short", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foo" + }), + }, + }, + { + name: "name too long", + wantErr: "name is too long", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" + }), + }, + }, + { + name: "disable writing and invalid output format", + wantErr: "valid output formats for this command are", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.DisableWritingFlag] = "true" + flagValues[globalflags.OutputFormatFlag] = print.PrettyOutputFormat + }), + }, + }, + { + name: "disable writing and default output format", + wantErr: "must be used with --output-format", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.DisableWritingFlag] = "true" + }), + }, + }, + { + name: "disable writing and valid output format", + want: fixtureByIdInputModel(func(model *inputModel) { + model.DisableWriting = true + model.OutputFormat = print.YAMLOutputFormat + }), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.DisableWritingFlag] = "true" + flagValues[globalflags.OutputFormatFlag] = print.YAMLOutputFormat + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "invalid expiration format", + wantErr: "invalid time string format", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "invalid" + }), + }, + }, + { + name: "expiration too short", + wantErr: "expiration is too small", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "1s" + }), + }, + }, + { + name: "expiration too long", + wantErr: "expiration is too large", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "13M" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + waiter kubeconfigWaiter + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "run by id success", + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{}, + }, + }, + { + name: "run by name success", + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{}, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{}, + }, + }, + { + name: "instance not found error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{waitNotFound: true}, + }, + }, + { + name: "get kubeconfig by id API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{waitFails: true}, + }, + }, + { + name: "get kubeconfig by name API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{waitFails: true}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + waiter: &mockKubeconfigWaiter{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Override production waiterProvider package level variable for testing + prodWaiterProvider := waiterProvider + waiterProvider = &testWaiterFactoryProvider{waiter: tt.args.waiter} + defer func() { waiterProvider = prodWaiterProvider }() + + _, err := run(testCtx, tt.args.model, tt.args.client) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *createRequestSpec + args args + }{ + { + name: "by id", + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + Expiration: int64(commonKubeconfig.ExpirationSecondsDefault), + }, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "by name", + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + Expiration: int64(commonKubeconfig.ExpirationSecondsDefault), + }, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(createRequestSpec{}, "Execute")) + }) + } +} + +func TestGetWaiterFactory(t *testing.T) { + type args struct { + model *inputModel + } + + tests := []struct { + name string + wantErr error + want bool + args args + }{ + { + name: "by id", + want: true, + args: args{ + model: fixtureByIdInputModel(), + }, + }, + { + name: "by name", + want: true, + args: args{ + model: fixtureByNameInputModel(), + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + want: false, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + }, + }, + { + name: "unknown identifier", + wantErr: &commonErr.InvalidIdentifierError{}, + want: false, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier.Flag = "unknown" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getWaiterFactory(testCtx, tt.args.model) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.want && got == nil { + t.Fatal("expected non-nil waiter factory") + } + if !tt.want && got != nil { + t.Fatal("expected nil waiter factory") + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + kubeconfig *edge.Kubeconfig + } + + tests := []struct { + name string + wantErr any + args args + }{ + { + name: "no kubeconfig", + wantErr: true, + args: args{ + model: fixtureByIdInputModel(), + kubeconfig: nil, + }, + }, + { + name: "kubeconfig with nil kubeconfig data", + wantErr: true, + args: args{ + model: fixtureByIdInputModel(), + kubeconfig: &edge.Kubeconfig{Kubeconfig: nil}, + }, + }, + { + name: "output json with disable writing", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output yaml with disable writing", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output default with disable writing", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output by name with json format and disable writing", + args: args{ + model: fixtureByNameInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output by name with yaml format and disable writing", + args: args{ + model: fixtureByNameInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "output by name default with disable writing", + args: args{ + model: fixtureByNameInputModel(func(model *inputModel) { + model.DisableWriting = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "file writing enabled (default behavior)", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.AssumeYes = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "file writing with overwrite enabled", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.Overwrite = true + model.AssumeYes = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + { + name: "file writing with switch context enabled", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.SwitchContext = true + model.AssumeYes = true + }), + kubeconfig: &edge.Kubeconfig{Kubeconfig: testKubeconfigMap()}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.model, tt.args.kubeconfig) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} diff --git a/internal/cmd/beta/edge/kubeconfig/kubeconfig.go b/internal/cmd/beta/edge/kubeconfig/kubeconfig.go new file mode 100644 index 000000000..b44c2e1a4 --- /dev/null +++ b/internal/cmd/beta/edge/kubeconfig/kubeconfig.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package kubeconfig + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/kubeconfig/create" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "kubeconfig", + Short: "Provides functionality for edge kubeconfig.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) kubeconfig management.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) +} diff --git a/internal/cmd/beta/edge/plans/list/list.go b/internal/cmd/beta/edge/plans/list/list.go new file mode 100755 index 000000000..e8b8607fd --- /dev/null +++ b/internal/cmd/beta/edge/plans/list/list.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// User input struct for the command +const ( + limitFlag = "limit" +) + +// Struct to model user input (arguments and/or flags) +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +// listRequestSpec captures the details of the request for testing. +type listRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Limit *int64 + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.PlanList, error) +} + +// Command constructor +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists available edge service plans", + Long: "Lists available STACKIT Edge Cloud (STEC) service plans of a project", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all edge plans for a given project`, + `$ stackit beta edge-cloud plan list`), + examples.NewExample( + `Lists all edge plans for a given project and limits the output to two plans`, + fmt.Sprintf(`$ stackit beta edge-cloud plan list --%s 2`, limitFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + // If project label can't be determined, fall back to project ID + projectLabel = model.ProjectId + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Parse and validate user input then add it to the model + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) ([]edge.Plan, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + if resp == nil { + return nil, fmt.Errorf("list plans: empty response from API") + } + if resp.ValidPlans == nil { + return nil, fmt.Errorf("list plans: valid plans missing in response") + } + plans := *resp.ValidPlans + + // Truncate output + if spec.Limit != nil && len(plans) > int(*spec.Limit) { + plans = plans[:*spec.Limit] + } + + return plans, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*listRequestSpec, error) { + req := apiClient.ListPlansProject(ctx, model.ProjectId) + + return &listRequestSpec{ + ProjectID: model.ProjectId, + Limit: model.Limit, + Execute: req.Execute, + }, nil +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat, projectLabel string, plans []edge.Plan) error { + return p.OutputResult(outputFormat, plans, func() error { + // No plans found for project + if len(plans) == 0 { + p.Outputf("No plans found for project %q\n", projectLabel) + return nil + } + + // Display plans found for project in a table + table := tables.NewTable() + // List: only output the most important fields. Be sure to filter for any non-required fields. + table.SetHeader("ID", "NAME", "DESCRIPTION", "MAX EDGE HOSTS") + for i := range plans { + plan := plans[i] + table.AddRow( + utils.PtrString(plan.Id), + utils.PtrString(plan.Name), + utils.PtrString(plan.Description), + utils.PtrString(plan.MaxEdgeHosts)) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/edge/plans/list/list_test.go b/internal/cmd/beta/edge/plans/list/list_test.go new file mode 100755 index 000000000..084e7e8e8 --- /dev/null +++ b/internal/cmd/beta/edge/plans/list/list_test.go @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package list + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" +) + +// mockExecutable is a mock for the Executable interface +type mockExecutable struct { + executeFails bool + executeResp *edge.PlanList +} + +func (m *mockExecutable) Execute() (*edge.PlanList, error) { + if m.executeFails { + return nil, errors.New("API error") + } + + if m.executeResp != nil { + return m.executeResp, nil + } + return &edge.PlanList{ + ValidPlans: &[]edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + {Id: utils.Ptr("plan-2"), Name: utils.Ptr("Premium")}, + }, + }, nil +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct { + getPlansMock edge.ApiListPlansProjectRequest +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + if m.getPlansMock != nil { + return m.getPlansMock + } + return &mockExecutable{} +} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} + +func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { + return nil +} + +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "list success", + want: fixtureInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "limit invalid value", + wantErr: "invalid syntax", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + }, + }, + { + name: "limit is zero", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + }, + }, + { + name: "limit is negative", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-0" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want []edge.Plan + args args + }{ + { + name: "list success", + want: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + {Id: utils.Ptr("plan-2"), Name: utils.Ptr("Premium")}, + }, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with limit", + want: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(1)) + }), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with limit greater than items", + want: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + {Id: utils.Ptr("plan-2"), Name: utils.Ptr("Premium")}, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(5)) + }), + client: &mockAPIClient{}, + }, + }, + { + name: "list success with no items", + want: []edge.Plan{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getPlansMock: &mockExecutable{ + executeResp: &edge.PlanList{ValidPlans: &[]edge.Plan{}}, + }, + }, + }, + }, + { + name: "list API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getPlansMock: &mockExecutable{ + executeFails: true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + plans []edge.Plan + projectLabel string + } + + tests := []struct { + name string + wantErr error + args args + }{ + { + name: "output json", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + plans: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + }, + projectLabel: "test-project", + }, + }, + { + name: "output yaml", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + }), + plans: []edge.Plan{ + {Id: utils.Ptr("plan-1"), Name: utils.Ptr("Standard")}, + }, + projectLabel: "test-project", + }, + }, + { + name: "output default with plans", + args: args{ + model: fixtureInputModel(), + plans: []edge.Plan{ + { + Id: utils.Ptr("plan-1"), + Name: utils.Ptr("Standard"), + Description: utils.Ptr("Standard plan description"), + }, + { + Id: utils.Ptr("plan-2"), + Name: utils.Ptr("Premium"), + Description: utils.Ptr("Premium plan description"), + }, + }, + projectLabel: "test-project", + }, + }, + { + name: "output default with no plans", + args: args{ + model: fixtureInputModel(), + plans: []edge.Plan{}, + projectLabel: "test-project", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + + err := outputResult(p, tt.args.model.OutputFormat, tt.args.projectLabel, tt.args.plans) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *listRequestSpec + args args + }{ + { + name: "success", + want: &listRequestSpec{ + ProjectID: testProjectId, + }, + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + client: &mockAPIClient{ + getPlansMock: &mockExecutable{}, + }, + }, + }, + { + name: "success with limit", + want: &listRequestSpec{ + ProjectID: testProjectId, + Limit: utils.Ptr(int64(10)), + }, + args: args{ + model: fixtureInputModel(), + client: &mockAPIClient{ + getPlansMock: &mockExecutable{}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(listRequestSpec{}, "Execute")) + }) + } +} diff --git a/internal/cmd/beta/edge/plans/plans.go b/internal/cmd/beta/edge/plans/plans.go new file mode 100644 index 000000000..d5ccb0721 --- /dev/null +++ b/internal/cmd/beta/edge/plans/plans.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package plans + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/plans/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "plans", + Short: "Provides functionality for edge service plans.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) service plan management.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/beta/edge/token/create/create.go b/internal/cmd/beta/edge/token/create/create.go new file mode 100755 index 000000000..cec854bcc --- /dev/null +++ b/internal/cmd/beta/edge/token/create/create.go @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + "github.com/stackitcloud/stackit-sdk-go/services/edge/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonKubeconfig "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/kubeconfig" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + identifier *commonValidation.Identifier + Expiration uint64 +} + +// createRequestSpec captures the details of the request for testing. +type createRequestSpec struct { + // Exported fields allow tests to inspect the request inputs + ProjectID string + Region string + InstanceId string + InstanceName string + Expiration int64 + + // Execute is a closure that wraps the actual SDK call + Execute func() (*edge.Token, error) +} + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls and therefore different wait handlers. +// tokenWaiter is an interface to abstract the different wait handlers so they can be used interchangeably. +type tokenWaiter interface { + WaitWithContext(context.Context) (*edge.Token, error) +} + +// A function that creates a token waiter +type tokenWaiterFactory = func(client *edge.APIClient) tokenWaiter + +// waiterFactoryProvider is an interface that provides token waiters so we can inject different impl. while testing. +type waiterFactoryProvider interface { + getTokenWaiter(ctx context.Context, model *inputModel, apiClient client.APIClient) (tokenWaiter, error) +} + +// productionWaiterFactoryProvider is the real implementation used in production. +// It handles the concrete client type casting required by the SDK's wait handlers. +type productionWaiterFactoryProvider struct{} + +func (p *productionWaiterFactoryProvider) getTokenWaiter(ctx context.Context, model *inputModel, apiClient client.APIClient) (tokenWaiter, error) { + waiterFactory, err := getWaiterFactory(ctx, model) + if err != nil { + return nil, err + } + // The waiter handler needs a concrete client type. We can safely cast here as the real implementation will always match. + edgeClient, ok := apiClient.(*edge.APIClient) + if !ok { + return nil, cliErr.NewBuildRequestError("failed to configure API client", nil) + } + return waiterFactory(edgeClient), nil +} + +// waiterProvider is the package-level variable used to get the waiter. +// It is initialized with the production implementation but can be overridden in tests. +var waiterProvider waiterFactoryProvider = &productionWaiterFactoryProvider{} + +// Command constructor +// Instance id and displayname are likely to be refactored in future. For the time being we decided to use flags +// instead of args to provide the instance-id xor displayname to uniquely identify an instance. The displayname +// is guaranteed to be unique within a given project as of today. The chosen flag over args approach ensures we +// won't need a breaking change of the CLI when we refactor the commands to take the identifier as arg at some point. +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a token for an edge instance", + Long: fmt.Sprintf("%s\n\n%s\n%s", + "Creates a token for a STACKIT Edge Cloud (STEC) instance.", + fmt.Sprintf("An expiration time can be set for the token. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is %d seconds.", commonKubeconfig.ExpirationSecondsDefault), + "Note: the format for the duration is , e.g. 30d for 30 days. You may not combine units."), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + fmt.Sprintf(`Create a token for the edge instance with %s "xxx".`, commonInstance.InstanceIdFlag), + fmt.Sprintf(`$ stackit beta edge-cloud token create --%s "xxx"`, commonInstance.InstanceIdFlag)), + examples.NewExample( + fmt.Sprintf(`Create a token for the edge instance with %s "xxx". The token will be valid for one day.`, commonInstance.DisplayNameFlag), + fmt.Sprintf(`$ stackit beta edge-cloud token create --%s "xxx" --expiration 1d`, commonInstance.DisplayNameFlag)), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + + // Parse user input (arguments and/or flags) + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + if model.Async { + return fmt.Errorf("async mode is not supported for token create") + } + + // Call API + resp, err := run(ctx, model, apiClient) + if err != nil { + return err + } + + // Handle output to printer + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(commonInstance.InstanceIdFlag, commonInstance.InstanceIdShorthand, "", commonInstance.InstanceIdUsage) + cmd.Flags().StringP(commonInstance.DisplayNameFlag, commonInstance.DisplayNameShorthand, "", commonInstance.DisplayNameUsage) + cmd.Flags().StringP(commonKubeconfig.ExpirationFlag, commonKubeconfig.ExpirationShorthand, "", commonKubeconfig.ExpirationUsage) + + identifierFlags := []string{commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag} + cmd.MarkFlagsMutuallyExclusive(identifierFlags...) // InstanceId xor DisplayName + cmd.MarkFlagsOneRequired(identifierFlags...) +} + +// Parse user input (arguments and/or flags) +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // Generate input model based on chosen flags + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + // Parse and validate user input then add it to the model + id, err := commonValidation.GetValidatedInstanceIdentifier(p, cmd) + if err != nil { + return nil, err + } + model.identifier = id + + // Parse and validate kubeconfig expiration time + if expString := flags.FlagToStringPointer(p, cmd, commonKubeconfig.ExpirationFlag); expString != nil { + expTime, err := utils.ConvertToSeconds(*expString) + if err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.ExpirationFlag, + Details: err.Error(), + } + } + if err := commonKubeconfig.ValidateExpiration(&expTime); err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: commonKubeconfig.ExpirationFlag, + Details: err.Error(), + } + } + model.Expiration = expTime + } else { + // Default expiration is 1 hour + defaultExp := uint64(commonKubeconfig.ExpirationSecondsDefault) + model.Expiration = defaultExp + } + + // Make sure to only output if the format is not none + if globalFlags.OutputFormat == print.NoneOutputFormat { + return nil, &cliErr.FlagValidationError{ + Flag: globalflags.OutputFormatFlag, + Details: fmt.Sprintf("valid formats for this command are: %s", fmt.Sprintf("%s, %s, %s", print.PrettyOutputFormat, print.JSONOutputFormat, print.YAMLOutputFormat)), + } + } + + // Log the parsed model if --verbosity is set to debug + p.DebugInputModel(model) + return &model, nil +} + +// Run is the main execution function used by the command runner. +// It is decoupled from TTY output to have the ability to mock the API client during testing. +func run(ctx context.Context, model *inputModel, apiClient client.APIClient) (*edge.Token, error) { + spec, err := buildRequest(ctx, model, apiClient) + if err != nil { + return nil, err + } + + resp, err := spec.Execute() + if err != nil { + return nil, cliErr.NewRequestFailedError(err) + } + + return resp, nil +} + +// buildRequest constructs the spec that can be tested. +func buildRequest(ctx context.Context, model *inputModel, apiClient client.APIClient) (*createRequestSpec, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + spec := &createRequestSpec{ + ProjectID: model.ProjectId, + Region: model.Region, + Expiration: int64(model.Expiration), // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + } + + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + spec.InstanceId = model.identifier.Value + case commonInstance.DisplayNameFlag: + spec.InstanceName = model.identifier.Value + default: + return nil, fmt.Errorf("%w: %w", cliErr.NewBuildRequestError("invalid identifier flag", nil), commonErr.NewInvalidIdentifierError(model.identifier.Flag)) + } + + // Closure used to decouple the actual SDK call for easier testing + spec.Execute = func() (*edge.Token, error) { + // Get the waiter from the provider (handles client type casting internally) + waiter, err := waiterProvider.getTokenWaiter(ctx, model, apiClient) + if err != nil { + return nil, err + } + + return waiter.WaitWithContext(ctx) + } + + return spec, nil +} + +// Returns a factory function to create the appropriate waiter based on the input model. +func getWaiterFactory(ctx context.Context, model *inputModel) (tokenWaiterFactory, error) { + if model == nil || model.identifier == nil { + return nil, commonErr.NewNoIdentifierError("") + } + + // The tokenWaitHandlers don't wait for the token to be created, but for the instance to be ready to return a token. + // Convert uint64 to int64 to match the API's type. + var expiration = int64(model.Expiration) // #nosec G115 ValidateExpiration ensures safe bounds, conversion is safe + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag: + factory := func(c *edge.APIClient) tokenWaiter { + return wait.TokenWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value, &expiration) + } + return factory, nil + case commonInstance.DisplayNameFlag: + factory := func(c *edge.APIClient) tokenWaiter { + return wait.TokenByInstanceNameWaitHandler(ctx, c, model.ProjectId, model.Region, model.identifier.Value, &expiration) + } + return factory, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} + +// Output result based on the configured output format +func outputResult(p *print.Printer, outputFormat string, token *edge.Token) error { + if token == nil || token.Token == nil { + // This is only to prevent nil pointer deref. + // As long as the API behaves as defined by it's spec, instance can not be empty (HTTP 200 with an empty body) + return fmt.Errorf("no token returned from the API") + } + tokenString := *token.Token + + return p.OutputResult(outputFormat, token, func() error { + p.Outputln(tokenString) + return nil + }) +} diff --git a/internal/cmd/beta/edge/token/create/create_test.go b/internal/cmd/beta/edge/token/create/create_test.go new file mode 100755 index 000000000..c380da959 --- /dev/null +++ b/internal/cmd/beta/edge/token/create/create_test.go @@ -0,0 +1,676 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package create + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/client" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + commonKubeconfig "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/kubeconfig" + commonValidation "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/validation" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testProjectId = uuid.NewString() + testRegion = "eu01" + testInstanceId = "instance" + testDisplayName = "test" + testExpiration = "1h" +) + +// mockTokenWaiter is a mock for the tokenWaiter interface +type mockTokenWaiter struct { + waitFails bool + waitNotFound bool + waitResp *edge.Token +} + +func (m *mockTokenWaiter) WaitWithContext(_ context.Context) (*edge.Token, error) { + if m.waitFails { + return nil, errors.New("wait error") + } + if m.waitNotFound { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: http.StatusNotFound, + } + } + if m.waitResp != nil { + return m.waitResp, nil + } + + // Default token response + tokenString := "test-token-string" + return &edge.Token{ + Token: &tokenString, + }, nil +} + +// testWaiterFactoryProvider is a test implementation that returns mock waiters. +type testWaiterFactoryProvider struct { + waiter tokenWaiter +} + +func (t *testWaiterFactoryProvider) getTokenWaiter(_ context.Context, model *inputModel, _ client.APIClient) (tokenWaiter, error) { + if model == nil || model.identifier == nil { + return nil, &commonErr.NoIdentifierError{} + } + + // Validate identifier like the real implementation + switch model.identifier.Flag { + case commonInstance.InstanceIdFlag, commonInstance.DisplayNameFlag: + // Return our mock waiter directly, bypassing the client type casting issue + return t.waiter, nil + default: + return nil, commonErr.NewInvalidIdentifierError(model.identifier.Flag) + } +} + +// mockAPIClient is a mock for the edge.APIClient interface +type mockAPIClient struct{} + +// Unused methods to satisfy the interface +func (m *mockAPIClient) GetTokenByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceIdRequest { + return nil +} + +func (m *mockAPIClient) GetTokenByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetTokenByInstanceNameRequest { + return nil +} + +func (m *mockAPIClient) ListPlansProject(_ context.Context, _ string) edge.ApiListPlansProjectRequest { + return nil +} + +func (m *mockAPIClient) CreateInstance(_ context.Context, _, _ string) edge.ApiCreateInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstance(_ context.Context, _, _, _ string) edge.ApiGetInstanceRequest { + return nil +} +func (m *mockAPIClient) GetInstanceByName(_ context.Context, _, _, _ string) edge.ApiGetInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) ListInstances(_ context.Context, _, _ string) edge.ApiListInstancesRequest { + return nil +} +func (m *mockAPIClient) UpdateInstance(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceRequest { + return nil +} +func (m *mockAPIClient) UpdateInstanceByName(_ context.Context, _, _, _ string) edge.ApiUpdateInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) DeleteInstance(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceRequest { + return nil +} +func (m *mockAPIClient) DeleteInstanceByName(_ context.Context, _, _, _ string) edge.ApiDeleteInstanceByNameRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceId(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceIdRequest { + return nil +} +func (m *mockAPIClient) GetKubeconfigByInstanceName(_ context.Context, _, _, _ string) edge.ApiGetKubeconfigByInstanceNameRequest { + return nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + commonInstance.InstanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureByIdInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(false, mods...) +} + +func fixtureByNameInputModel(mods ...func(model *inputModel)) *inputModel { + return fixtureInputModel(true, mods...) +} + +func fixtureInputModel(useName bool, mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Expiration: uint64(commonKubeconfig.ExpirationSecondsDefault), // Default 1 hour + } + + if useName { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: testDisplayName, + } + } else { + model.identifier = &commonValidation.Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: testInstanceId, + } + } + + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + type args struct { + flags map[string]string + cmpOpts []testUtils.ValueComparisonOption + } + + tests := []struct { + name string + wantErr any + want *inputModel + args args + }{ + { + name: "by id", + want: fixtureByIdInputModel(), + args: args{ + flags: fixtureFlagValues(), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by name", + want: fixtureByNameInputModel(), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "with expiration", + want: fixtureByIdInputModel(func(model *inputModel) { + model.Expiration = uint64(3600) + }), + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = testExpiration + }), + cmpOpts: []testUtils.ValueComparisonOption{ + testUtils.WithAllowUnexported(inputModel{}), + }, + }, + }, + { + name: "by id and name", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.DisplayNameFlag] = testDisplayName + }), + }, + }, + { + name: "no flag values", + wantErr: true, + args: args{ + flags: map[string]string{}, + }, + }, + { + name: "project id missing", + wantErr: &cliErr.ProjectIdError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + }, + }, + { + name: "project id empty", + wantErr: "value cannot be empty", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + }, + }, + { + name: "project id invalid", + wantErr: "invalid UUID length", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + }, + }, + { + name: "instance id missing", + wantErr: true, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + }), + }, + }, + { + name: "instance id empty", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "" + }), + }, + }, + { + name: "instance id too long", + wantErr: &cliErr.FlagValidationError{}, + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "invalid-instance-id" + }), + }, + }, + { + name: "instance id too short", + wantErr: "id is too short", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonInstance.InstanceIdFlag] = "id" + }), + }, + }, + { + name: "name too short", + wantErr: "name is too short", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foo" + }), + }, + }, + { + name: "name too long", + wantErr: "name is too long", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commonInstance.InstanceIdFlag) + flagValues[commonInstance.DisplayNameFlag] = "foofoofoo" + }), + }, + }, + { + name: "invalid expiration format", + wantErr: "invalid time string format", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "invalid" + }), + }, + }, + { + name: "expiration too short", + wantErr: "expiration is too small", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "1s" + }), + }, + }, + { + name: "expiration too long", + wantErr: "expiration is too large", + args: args{ + flags: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[commonKubeconfig.ExpirationFlag] = "13M" + }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caseOpts := []testUtils.ParseInputCaseOption{} + if len(tt.args.cmpOpts) > 0 { + caseOpts = append(caseOpts, testUtils.WithParseInputCmpOptions(tt.args.cmpOpts...)) + } + + testUtils.RunParseInputCase(t, testUtils.ParseInputTestCase[*inputModel]{ + Name: tt.name, + Flags: tt.args.flags, + WantModel: tt.want, + WantErr: tt.wantErr, + CmdFactory: NewCmd, + ParseInputFunc: func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, + }, caseOpts...) + }) + } +} + +func TestRun(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + waiter tokenWaiter + } + tests := []struct { + name string + wantErr any + wantToken bool + args args + }{ + { + name: "run by id success", + wantToken: true, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{}, + }, + }, + { + name: "run by name success", + wantToken: true, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{}, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{}, + }, + }, + { + name: "instance not found error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{waitNotFound: true}, + }, + }, + { + name: "get token by id API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{waitFails: true}, + }, + }, + { + name: "get token by name API error", + wantErr: &cliErr.RequestFailedError{}, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{waitFails: true}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + waiter: &mockTokenWaiter{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Override production waiterProvider package level variable for testing + prodWaiterProvider := waiterProvider + waiterProvider = &testWaiterFactoryProvider{waiter: tt.args.waiter} + defer func() { waiterProvider = prodWaiterProvider }() + + got, err := run(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if tt.wantToken && got == nil { + t.Fatal("expected non-nil token") + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + type args struct { + model *inputModel + client client.APIClient + } + + tests := []struct { + name string + wantErr error + want *createRequestSpec + args args + }{ + { + name: "by id", + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceId: testInstanceId, + Expiration: int64(commonKubeconfig.ExpirationSecondsDefault), + }, + args: args{ + model: fixtureByIdInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "by name", + want: &createRequestSpec{ + ProjectID: testProjectId, + Region: testRegion, + InstanceName: testDisplayName, + Expiration: int64(commonKubeconfig.ExpirationSecondsDefault), + }, + args: args{ + model: fixtureByNameInputModel(), + client: &mockAPIClient{}, + }, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + }), + client: &mockAPIClient{}, + }, + }, + { + name: "identifier invalid", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{ + model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = &commonValidation.Identifier{ + Flag: "unknown-flag", + Value: "some-value", + } + }), + client: &mockAPIClient{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildRequest(testCtx, tt.args.model, tt.args.client) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + testUtils.AssertValue(t, got, tt.want, testUtils.WithIgnoreFields(createRequestSpec{}, "Execute")) + }) + } +} + +func TestGetWaiterFactory(t *testing.T) { + type args struct { + model *inputModel + } + tests := []struct { + name string + want bool + wantErr error + args args + }{ + { + name: "by id", + want: true, + args: args{model: fixtureByIdInputModel()}, + }, + { + name: "by name", + want: true, + args: args{model: fixtureByNameInputModel()}, + }, + { + name: "no id or name", + wantErr: &commonErr.NoIdentifierError{}, + args: args{model: fixtureInputModel(false, func(model *inputModel) { + model.identifier = nil + })}, + }, + { + name: "unknown identifier", + wantErr: &commonErr.InvalidIdentifierError{}, + args: args{model: fixtureInputModel(false, func(model *inputModel) { + model.identifier.Flag = "unknown" + })}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getWaiterFactory(testCtx, tt.args.model) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.want && got == nil { + t.Fatal("expected non-nil waiter factory") + } + if !tt.want && got != nil { + t.Fatal("expected nil waiter factory") + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + token *edge.Token + } + tests := []struct { + name string + wantErr any + args args + }{ + { + name: "default output format", + args: args{ + model: fixtureByIdInputModel(), + token: &edge.Token{ + Token: func() *string { s := "test-token"; return &s }(), + }, + }, + }, + { + name: "JSON output format", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + token: &edge.Token{ + Token: func() *string { s := "test-token"; return &s }(), + }, + }, + }, + { + name: "YAML output format", + args: args{ + model: fixtureByIdInputModel(func(model *inputModel) { + model.OutputFormat = print.YAMLOutputFormat + }), + token: &edge.Token{ + Token: func() *string { s := "test-token"; return &s }(), + }, + }, + }, + { + name: "nil token", + wantErr: true, + args: args{ + model: fixtureByIdInputModel(), + token: nil, + }, + }, + { + name: "nil token string", + wantErr: true, + args: args{ + model: fixtureByIdInputModel(), + token: &edge.Token{Token: nil}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + err := outputResult(p, tt.args.model.OutputFormat, tt.args.token) + testUtils.AssertError(t, err, tt.wantErr) + }) + } +} diff --git a/internal/cmd/beta/edge/token/token.go b/internal/cmd/beta/edge/token/token.go new file mode 100644 index 000000000..8fd725a72 --- /dev/null +++ b/internal/cmd/beta/edge/token/token.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package token + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/edge/token/create" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Provides functionality for edge service token.", + Long: "Provides functionality for STACKIT Edge Cloud (STEC) token management.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) +} diff --git a/internal/cmd/beta/intake/create/create.go b/internal/cmd/beta/intake/create/create.go new file mode 100644 index 000000000..00d54349e --- /dev/null +++ b/internal/cmd/beta/intake/create/create.go @@ -0,0 +1,248 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + displayNameFlag = "display-name" + runnerIdFlag = "runner-id" + descriptionFlag = "description" + labelsFlag = "labels" + catalogURIFlag = "catalog-uri" + catalogWarehouseFlag = "catalog-warehouse" + catalogNamespaceFlag = "catalog-namespace" + catalogTableNameFlag = "catalog-table-name" + catalogPartitioningFlag = "catalog-partitioning" + catalogPartitionByFlag = "catalog-partition-by" + catalogAuthTypeFlag = "catalog-auth-type" + dremioTokenEndpointFlag = "dremio-token-endpoint" //nolint:gosec // false positive + dremioPatFlag = "dremio-pat" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + + // Top-level fields + DisplayName *string + RunnerId *string + Description *string + Labels *map[string]string + + // Catalog fields + CatalogURI *string + CatalogWarehouse *string + CatalogNamespace *string + CatalogTableName *string + CatalogPartitioning *string + CatalogPartitionBy *[]string + + // Auth fields + CatalogAuthType *string + DremioTokenEndpoint *string + DremioToken *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new Intake", + Long: "Creates a new Intake.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new Intake with required parameters`, + `$ stackit beta intake create --display-name my-intake --runner-id xxx --catalog-auth-type none --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse"`), + examples.NewExample( + `Create a new Intake with a description, labels, and Dremio authentication`, + `$ stackit beta intake create --display-name my-intake --runner-id xxx --description "Production intake" --labels "env=prod,team=billing" --catalog-auth-type "dremio" --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" --dremio-token-endpoint "https://auth.dremio.cloud/oauth/token" --dremio-pat "MY_TOKEN"`), + examples.NewExample( + `Create a new Intake with manual partitioning by a date field`, + `$ stackit beta intake create --display-name my-partitioned-intake --runner-id xxx --catalog-auth-type "none" --catalog-uri "http://dremio.example.com" --catalog-warehouse "my-warehouse" --catalog-partitioning "manual" --catalog-partition-by "day(__intake_ts)"`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create an Intake for project %q?", projectLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Intake: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Creating STACKIT Intake instance", func() error { + _, err = wait.CreateOrUpdateIntakeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, resp.GetId()).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance creation: %w", err) + } + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + // Top-level flags + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Var(flags.UUIDFlag(), runnerIdFlag, "The UUID of the Intake Runner to use") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelsFlag, nil, "Labels in key=value format, separated by commas. Example: --labels \"key1=value1,key2=value2\"") + + // Catalog flags + cmd.Flags().String(catalogURIFlag, "", "The URI to the Iceberg catalog endpoint") + cmd.Flags().String(catalogWarehouseFlag, "", "The Iceberg warehouse to connect to") + cmd.Flags().String(catalogNamespaceFlag, "", "The namespace to which data shall be written (default: 'intake')") + cmd.Flags().String(catalogTableNameFlag, "", "The table name to identify the table in Iceberg") + cmd.Flags().String(catalogPartitioningFlag, "", "The target table's partitioning. One of 'none', 'intake-time', 'manual'") + cmd.Flags().StringSlice(catalogPartitionByFlag, nil, "List of Iceberg partitioning expressions. Only used when --catalog-partitioning is 'manual'") + + // Auth flags + cmd.Flags().String(catalogAuthTypeFlag, "", "Authentication type for the catalog (e.g., 'none', 'dremio')") + cmd.Flags().String(dremioTokenEndpointFlag, "", "Dremio OAuth 2.0 token endpoint URL. Required if auth-type is 'dremio'") + cmd.Flags().String(dremioPatFlag, "", "Dremio personal access token. Required if auth-type is 'dremio'") + + err := flags.MarkFlagsRequired(cmd, displayNameFlag, runnerIdFlag, catalogURIFlag, catalogWarehouseFlag, catalogAuthTypeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + // Top-level fields + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + RunnerId: flags.FlagToStringPointer(p, cmd, runnerIdFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + + // Catalog fields + CatalogURI: flags.FlagToStringPointer(p, cmd, catalogURIFlag), + CatalogWarehouse: flags.FlagToStringPointer(p, cmd, catalogWarehouseFlag), + CatalogNamespace: flags.FlagToStringPointer(p, cmd, catalogNamespaceFlag), + CatalogTableName: flags.FlagToStringPointer(p, cmd, catalogTableNameFlag), + CatalogPartitioning: flags.FlagToStringPointer(p, cmd, catalogPartitioningFlag), + CatalogPartitionBy: flags.FlagToStringSlicePointer(p, cmd, catalogPartitionByFlag), + + // Auth fields + CatalogAuthType: flags.FlagToStringPointer(p, cmd, catalogAuthTypeFlag), + DremioTokenEndpoint: flags.FlagToStringPointer(p, cmd, dremioTokenEndpointFlag), + DremioToken: flags.FlagToStringPointer(p, cmd, dremioPatFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiCreateIntakeRequest { + req := apiClient.CreateIntake(ctx, model.ProjectId, model.Region) + + // Build catalog authentication + var catalogAuth *intake.CatalogAuth + if model.CatalogAuthType != nil { + authType := intake.CatalogAuthType(*model.CatalogAuthType) + catalogAuth = &intake.CatalogAuth{ + Type: &authType, + } + if *model.CatalogAuthType == "dremio" { + catalogAuth.Dremio = &intake.DremioAuth{ + TokenEndpoint: model.DremioTokenEndpoint, + PersonalAccessToken: model.DremioToken, + } + } + } + + var partitioning *intake.PartitioningType + if model.CatalogPartitioning != nil { + partitioning = utils.Ptr(intake.PartitioningType(*model.CatalogPartitioning)) + } + + // Build catalog + catalogPayload := intake.IntakeCatalog{ + Uri: model.CatalogURI, + Warehouse: model.CatalogWarehouse, + Namespace: model.CatalogNamespace, + TableName: model.CatalogTableName, + Partitioning: partitioning, + PartitionBy: model.CatalogPartitionBy, + Auth: catalogAuth, + } + + // Build main payload + payload := intake.CreateIntakePayload{ + DisplayName: model.DisplayName, + IntakeRunnerId: model.RunnerId, + Description: model.Description, + Labels: model.Labels, + Catalog: &catalogPayload, + } + req = req.CreateIntakePayload(payload) + + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *intake.IntakeResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered creation of Intake for project %q, but no intake ID was returned.\n", projectLabel) + return nil + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s Intake for project %q. Intake ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/create/create_test.go b/internal/cmd/beta/intake/create/create_test.go new file mode 100644 index 000000000..9c6c37213 --- /dev/null +++ b/internal/cmd/beta/intake/create/create_test.go @@ -0,0 +1,362 @@ +package create + +import ( + "context" + "testing" + + "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/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" + + testDisplayName = "testintake" + testDescription = "This is a test intake" + testLabelsString = "env=test,team=dev" + testCatalogURI = "http://dremio.example.com" + testCatalogWarehouse = "my-warehouse" + testCatalogNamespace = "test-namespace" + testCatalogTableName = "test-table" + testCatalogPartitioning = "manual" + testCatalogPartitionByFlag = "year,month" + testCatalogAuthType = "dremio" + testDremioTokenEndpoint = "https://auth.dremio.cloud/oauth/token" //nolint:gosec // false url + testDremioToken = "dremio-secret-token" +) + +var ( + // testCtx dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testRunnerId = uuid.NewString() + + testLabels = map[string]string{"env": "test", "team": "dev"} + testCatalogPartitionBy = []string{"year", "month"} +) + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + runnerIdFlag: testRunnerId, + descriptionFlag: testDescription, + labelsFlag: testLabelsString, + catalogURIFlag: testCatalogURI, + catalogWarehouseFlag: testCatalogWarehouse, + catalogNamespaceFlag: testCatalogNamespace, + catalogTableNameFlag: testCatalogTableName, + catalogPartitionByFlag: testCatalogPartitionByFlag, + catalogPartitioningFlag: testCatalogPartitioning, + catalogAuthTypeFlag: testCatalogAuthType, + dremioTokenEndpointFlag: testDremioTokenEndpoint, + dremioPatFlag: testDremioToken, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: utils.Ptr(testDisplayName), + RunnerId: utils.Ptr(testRunnerId), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + CatalogURI: utils.Ptr(testCatalogURI), + CatalogWarehouse: utils.Ptr(testCatalogWarehouse), + CatalogNamespace: utils.Ptr(testCatalogNamespace), + CatalogTableName: utils.Ptr(testCatalogTableName), + CatalogPartitionBy: utils.Ptr(testCatalogPartitionBy), + CatalogPartitioning: utils.Ptr(testCatalogPartitioning), + CatalogAuthType: utils.Ptr(testCatalogAuthType), + DremioTokenEndpoint: utils.Ptr(testDremioTokenEndpoint), + DremioToken: utils.Ptr(testDremioToken), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureCreatePayload generates a CreateIntakePayload for tests +func fixtureCreatePayload(mods ...func(payload *intake.CreateIntakePayload)) intake.CreateIntakePayload { + authType := intake.CatalogAuthType(testCatalogAuthType) + testPartitioningType := intake.PartitioningType(testCatalogPartitioning) + payload := intake.CreateIntakePayload{ + DisplayName: utils.Ptr(testDisplayName), + IntakeRunnerId: utils.Ptr(testRunnerId), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + Catalog: &intake.IntakeCatalog{ + Uri: utils.Ptr(testCatalogURI), + Warehouse: utils.Ptr(testCatalogWarehouse), + Namespace: utils.Ptr(testCatalogNamespace), + TableName: utils.Ptr(testCatalogTableName), + Partitioning: &testPartitioningType, + PartitionBy: utils.Ptr(testCatalogPartitionBy), + Auth: &intake.CatalogAuth{ + Type: &authType, + Dremio: &intake.DremioAuth{ + TokenEndpoint: utils.Ptr(testDremioTokenEndpoint), + PersonalAccessToken: utils.Ptr(testDremioToken), + }, + }, + }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiCreateIntakeRequest)) intake.ApiCreateIntakeRequest { + request := testClient.CreateIntake(testCtx, testProjectId, testRegion) + request = request.CreateIntakePayload(fixtureCreatePayload()) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "runner-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, runnerIdFlag) + }), + isValid: false, + }, + { + description: "catalog-uri missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, catalogURIFlag) + }), + isValid: false, + }, + { + description: "catalog-warehouse missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, catalogWarehouseFlag) + }), + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + runnerIdFlag: testRunnerId, + catalogURIFlag: testCatalogURI, + catalogWarehouseFlag: testCatalogWarehouse, + catalogAuthTypeFlag: testCatalogAuthType, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + model.CatalogNamespace = nil + model.CatalogTableName = nil + model.CatalogPartitioning = nil + model.CatalogPartitionBy = nil + model.DremioTokenEndpoint = nil + model.DremioToken = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiCreateIntakeRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optionals", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + model.CatalogNamespace = nil + model.CatalogTableName = nil + model.CatalogPartitioning = nil + model.CatalogPartitionBy = nil + model.CatalogAuthType = nil + model.DremioTokenEndpoint = nil + model.DremioToken = nil + }), + expectedRequest: fixtureRequest(func(request *intake.ApiCreateIntakeRequest) { + *request = (*request).CreateIntakePayload(fixtureCreatePayload(func(payload *intake.CreateIntakePayload) { + payload.Description = nil + payload.Labels = nil + payload.Catalog.Namespace = nil + payload.Catalog.TableName = nil + payload.Catalog.PartitionBy = nil + payload.Catalog.Partitioning = nil + payload.Catalog.Auth = nil + })) + }), + }, + { + description: "auth type none", + model: fixtureInputModel(func(model *inputModel) { + model.CatalogAuthType = utils.Ptr("none") + model.DremioTokenEndpoint = nil + model.DremioToken = nil + }), + expectedRequest: fixtureRequest(func(request *intake.ApiCreateIntakeRequest) { + *request = (*request).CreateIntakePayload(fixtureCreatePayload(func(payload *intake.CreateIntakePayload) { + authType := intake.CatalogAuthType("none") + payload.Catalog.Auth.Type = &authType + payload.Catalog.Auth.Dremio = nil + })) + }), + }, + } + + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *intake.IntakeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &intake.IntakeResponse{Id: utils.Ptr("intake-id-123")}, + }, + wantErr: false, + }, + { + name: "default output - async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "my-project", + resp: &intake.IntakeResponse{Id: utils.Ptr("intake-id-123")}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &intake.IntakeResponse{Id: utils.Ptr("intake-id-123")}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: nil, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/delete/delete.go b/internal/cmd/beta/intake/delete/delete.go new file mode 100644 index 000000000..f49118dfe --- /dev/null +++ b/internal/cmd/beta/intake/delete/delete.go @@ -0,0 +1,115 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + intakeIdArg = "INTAKE_ID" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string +} + +// NewCmd creates a new cobra command for deleting an Intake +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", intakeIdArg), + Short: "Deletes an Intake", + Long: "Deletes an Intake.", + Args: args.SingleArg(intakeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an Intake with ID "xxx"`, + `$ stackit beta intake delete xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to delete an Intake %q?", model.IntakeId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err = req.Execute(); err != nil { + return fmt.Errorf("delete Intake: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Deleting STACKIT Intake instance", func() error { + _, err = wait.DeleteIntakeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Outputf("%s STACKIT Intake instance %s \n", operationState, model.IntakeId) + + return nil + }, + } + return cmd +} + +// parseInput parses the command arguments and flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + intakeId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to delete an Intake +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiDeleteIntakeRequest { + req := apiClient.DeleteIntake(ctx, model.ProjectId, model.Region, model.IntakeId) + return req +} diff --git a/internal/cmd/beta/intake/delete/delete_test.go b/internal/cmd/beta/intake/delete/delete_test.go new file mode 100644 index 000000000..ce673fabc --- /dev/null +++ b/internal/cmd/beta/intake/delete/delete_test.go @@ -0,0 +1,156 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + // testCtx is a dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient is a mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() +) + +// fixtureArgValues generates a slice of arguments for tests +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testIntakeId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiDeleteIntakeRequest)) intake.ApiDeleteIntakeRequest { + request := testClient.DeleteIntake(testCtx, testProjectId, testRegion, testIntakeId) + 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 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiDeleteIntakeRequest + }{ + { + 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/beta/intake/describe/describe.go b/internal/cmd/beta/intake/describe/describe.go new file mode 100644 index 000000000..7c0926591 --- /dev/null +++ b/internal/cmd/beta/intake/describe/describe.go @@ -0,0 +1,145 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + intakeIdArg = "INTAKE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", intakeIdArg), + Short: "Shows details of an Intake", + Long: "Shows details of an Intake.", + Args: args.SingleArg(intakeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an Intake with ID "xxx"`, + `$ stackit beta intake describe xxx`), + examples.NewExample( + `Get details of an Intake with ID "xxx" in JSON format`, + `$ stackit beta intake describe xxx --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Intake: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + intakeId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiGetIntakeRequest { + req := apiClient.GetIntake(ctx, model.ProjectId, model.Region, model.IntakeId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, intk *intake.IntakeResponse) error { + if intk == nil { + return fmt.Errorf("received nil response, could not display details") + } + + return p.OutputResult(outputFormat, intk, func() error { + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", intk.GetId()) + table.AddRow("Name", intk.GetDisplayName()) + table.AddRow("State", intk.GetState()) + table.AddRow("Runner ID", intk.GetIntakeRunnerId()) + table.AddRow("Created", intk.GetCreateTime()) + table.AddRow("Labels", intk.GetLabels()) + + if description := intk.GetDescription(); description != "" { + table.AddRow("Description", description) + } + + if failureMessage := intk.GetFailureMessage(); failureMessage != "" { + table.AddRow("Failure Message", failureMessage) + } + + table.AddSeparator() + table.AddRow("Ingestion URI", intk.GetUri()) + table.AddRow("Topic", intk.GetTopic()) + table.AddRow("Dead Letter Topic", intk.GetDeadLetterTopic()) + table.AddRow("Undelivered Messages", intk.GetUndeliveredMessageCount()) + + table.AddSeparator() + catalog := intk.GetCatalog() + table.AddRow("Catalog URI", catalog.GetUri()) + table.AddRow("Catalog Warehouse", catalog.GetWarehouse()) + if namespace := catalog.GetNamespace(); namespace != "" { + table.AddRow("Catalog Namespace", namespace) + } + if tableName := catalog.GetTableName(); tableName != "" { + table.AddRow("Catalog Table Name", tableName) + } + table.AddRow("Catalog Partitioning", catalog.GetPartitioning()) + if partitionBy := catalog.GetPartitionBy(); partitionBy != nil && len(*partitionBy) > 0 { + table.AddRow("Catalog Partition By", strings.Join(*partitionBy, ", ")) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/intake/describe/describe_test.go b/internal/cmd/beta/intake/describe/describe_test.go new file mode 100644 index 000000000..c2961c5b4 --- /dev/null +++ b/internal/cmd/beta/intake/describe/describe_test.go @@ -0,0 +1,193 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testIntakeId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiGetIntakeRequest)) intake.ApiGetIntakeRequest { + request := testClient.GetIntake(testCtx, testProjectId, testRegion, testIntakeId) + 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 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiGetIntakeRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + intakeResp *intake.IntakeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", intakeResp: &intake.IntakeResponse{Catalog: &intake.IntakeCatalog{}}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, intakeResp: &intake.IntakeResponse{Catalog: &intake.IntakeCatalog{}}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, intakeResp: &intake.IntakeResponse{Catalog: &intake.IntakeCatalog{}}}, + wantErr: false, + }, + { + name: "nil response", + args: args{intakeResp: nil}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.intakeResp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/intake.go b/internal/cmd/beta/intake/intake.go new file mode 100644 index 000000000..8e90bddd6 --- /dev/null +++ b/internal/cmd/beta/intake/intake.go @@ -0,0 +1,41 @@ +package intake + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/update" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// NewCmd creates the 'stackit intake' command +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "intake", + Short: "Provides functionality for intake", + Long: "Provides functionality for intake.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(runner.NewCmd(params)) + cmd.AddCommand(user.NewCmd(params)) + + // Intake instance subcommands + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) +} diff --git a/internal/cmd/beta/intake/list/list.go b/internal/cmd/beta/intake/list/list.go new file mode 100644 index 000000000..2f19c91f7 --- /dev/null +++ b/internal/cmd/beta/intake/list/list.go @@ -0,0 +1,151 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + limitFlag = "limit" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +// NewCmd creates a new cobra command for listing Intakes +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Intakes", + Long: "Lists all Intakes for the current project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Intakes`, + `$ stackit beta intake list`), + examples.NewExample( + `List all Intakes in JSON format`, + `$ stackit beta intake list --output-format json`), + examples.NewExample( + `List up to 5 Intakes`, + `$ stackit beta intake list --limit 5`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Intakes: %w", err) + } + intakes := resp.GetIntakes() + + // Truncate output + if model.Limit != nil && len(intakes) > int(*model.Limit) { + intakes = intakes[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(intakes) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, intakes) + }, + } + configureFlags(cmd) + return cmd +} + +// configureFlags adds the --limit flag to the command +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +// parseInput parses the command flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to list Intakes +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiListIntakesRequest { + req := apiClient.ListIntakes(ctx, model.ProjectId, model.Region) + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat, projectLabel string, intakes []intake.IntakeResponse) error { + return p.OutputResult(outputFormat, intakes, func() error { + if len(intakes) == 0 { + p.Outputf("No intakes found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATE", "RUNNER ID") + for i := range intakes { + intakeItem := intakes[i] + table.AddRow( + intakeItem.GetId(), + intakeItem.GetDisplayName(), + intakeItem.GetState(), + intakeItem.GetIntakeRunnerId(), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/intake/list/list_test.go b/internal/cmd/beta/intake/list/list_test.go new file mode 100644 index 000000000..15b5e714a --- /dev/null +++ b/internal/cmd/beta/intake/list/list_test.go @@ -0,0 +1,206 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "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/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testLimit = int64(5) +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiListIntakesRequest)) intake.ApiListIntakesRequest { + request := testClient.ListIntakes(testCtx, testProjectId, testRegion) + 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 limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = strconv.FormatInt(testLimit, 10) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(testLimit) + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "limit is zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "limit is negative", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiListIntakesRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + intakes []intake.IntakeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", intakes: []intake.IntakeResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, intakes: []intake.IntakeResponse{}}, + wantErr: false, + }, + { + name: "empty slice", + args: args{intakes: []intake.IntakeResponse{}}, + wantErr: false, + }, + { + name: "nil slice", + args: args{intakes: nil}, + wantErr: false, + }, + { + name: "empty intake in slice", + args: args{ + intakes: []intake.IntakeResponse{{}}, + }, + wantErr: false, + }, + { + name: "with project label", + args: args{ + projectLabel: "my-project", + intakes: []intake.IntakeResponse{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.intakes); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/runner/create/create.go b/internal/cmd/beta/intake/runner/create/create.go new file mode 100644 index 000000000..b64a116f9 --- /dev/null +++ b/internal/cmd/beta/intake/runner/create/create.go @@ -0,0 +1,169 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + displayNameFlag = "display-name" + maxMessageSizeKiBFlag = "max-message-size-kib" + maxMessagesPerHourFlag = "max-messages-per-hour" + descriptionFlag = "description" + labelFlag = "labels" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + DisplayName *string + MaxMessageSizeKiB *int64 + MaxMessagesPerHour *int64 + Description *string + Labels *map[string]string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new Intake Runner", + Long: "Creates a new Intake Runner.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new Intake Runner with a display name and message capacity limits`, + `$ stackit beta intake runner create --display-name my-runner --max-message-size-kib 1000 --max-messages-per-hour 5000`), + examples.NewExample( + `Create a new Intake Runner with a description and labels`, + `$ stackit beta intake runner create --display-name my-runner --max-message-size-kib 1000 --max-messages-per-hour 5000 --description "Main runner for production" --labels="env=prod,team=billing"`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create an Intake Runner for project %q?", projectLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Intake Runner: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Creating STACKIT Intake Runner", func() error { + _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, resp.GetId()).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake Runner creation: %w", err) + } + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Int64(maxMessageSizeKiBFlag, 0, "Maximum message size in KiB") + cmd.Flags().Int64(maxMessagesPerHourFlag, 0, "Maximum number of messages per hour") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelFlag, nil, "Labels in key=value format, separated by commas. Example: --labels \"key1=value1,key2=value2\"") + + err := flags.MarkFlagsRequired(cmd, displayNameFlag, maxMessageSizeKiBFlag, maxMessagesPerHourFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + MaxMessageSizeKiB: flags.FlagToInt64Pointer(p, cmd, maxMessageSizeKiBFlag), + MaxMessagesPerHour: flags.FlagToInt64Pointer(p, cmd, maxMessagesPerHourFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiCreateIntakeRunnerRequest { + // Start building the request by calling the base method with path parameters + req := apiClient.CreateIntakeRunner(ctx, model.ProjectId, model.Region) + + // Create the payload struct with data from the input model + payload := intake.CreateIntakeRunnerPayload{ + DisplayName: model.DisplayName, + MaxMessageSizeKiB: model.MaxMessageSizeKiB, + MaxMessagesPerHour: model.MaxMessagesPerHour, + Description: model.Description, + Labels: model.Labels, + } + // Attach the payload to the request builder + req = req.CreateIntakeRunnerPayload(payload) + + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *intake.IntakeRunnerResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered creation of Intake Runner for project %q, but no runner ID was returned.\n", projectLabel) + return nil + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s Intake Runner for project %q. Runner ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/runner/create/create_test.go b/internal/cmd/beta/intake/runner/create/create_test.go new file mode 100644 index 000000000..852e7339b --- /dev/null +++ b/internal/cmd/beta/intake/runner/create/create_test.go @@ -0,0 +1,295 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testDisplayName = "testrunner" + testMaxMessageSizeKiB = int64(1024) + testMaxMessagesPerHour = int64(10000) + testDescription = "This is a test runner" + testLabelsString = "env=test,team=dev" +) + +var ( + // testCtx dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + + testLabels = map[string]string{"env": "test", "team": "dev"} +) + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + maxMessageSizeKiBFlag: "1024", + maxMessagesPerHourFlag: "10000", + descriptionFlag: testDescription, + labelFlag: testLabelsString, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: utils.Ptr(testDisplayName), + MaxMessageSizeKiB: utils.Ptr(testMaxMessageSizeKiB), + MaxMessagesPerHour: utils.Ptr(testMaxMessagesPerHour), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureCreatePayload generates a CreateIntakeRunnerPayload for tests +func fixtureCreatePayload(mods ...func(payload *intake.CreateIntakeRunnerPayload)) intake.CreateIntakeRunnerPayload { + payload := intake.CreateIntakeRunnerPayload{ + DisplayName: utils.Ptr(testDisplayName), + MaxMessageSizeKiB: utils.Ptr(testMaxMessageSizeKiB), + MaxMessagesPerHour: utils.Ptr(testMaxMessagesPerHour), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiCreateIntakeRunnerRequest)) intake.ApiCreateIntakeRunnerRequest { + request := testClient.CreateIntakeRunner(testCtx, testProjectId, testRegion) + request = request.CreateIntakeRunnerPayload(fixtureCreatePayload()) + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "display name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "max message size missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, maxMessageSizeKiBFlag) + }), + isValid: false, + }, + { + description: "max messages per hour missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, maxMessagesPerHourFlag) + }), + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + maxMessageSizeKiBFlag: "1024", + maxMessagesPerHourFlag: "10000", + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + parseInputWrapper := func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + } + testutils.TestParseInput(t, NewCmd, parseInputWrapper, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiCreateIntakeRunnerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optionals", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *intake.ApiCreateIntakeRunnerRequest) { + *request = (*request).CreateIntakeRunnerPayload(fixtureCreatePayload(func(payload *intake.CreateIntakeRunnerPayload) { + payload.Description = nil + payload.Labels = nil + })) + }), + }, + } + + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "default output - async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "my-project", + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: nil, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/runner/delete/delete.go b/internal/cmd/beta/intake/runner/delete/delete.go new file mode 100644 index 000000000..5c7277bff --- /dev/null +++ b/internal/cmd/beta/intake/runner/delete/delete.go @@ -0,0 +1,115 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + runnerIdArg = "RUNNER_ID" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + RunnerId string +} + +// NewCmd creates a new cobra command for deleting an Intake Runner +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", runnerIdArg), + Short: "Deletes an Intake Runner", + Long: "Deletes an Intake Runner.", + Args: args.SingleArg(runnerIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an Intake Runner with ID "xxx"`, + `$ stackit beta intake runner delete xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to delete Intake Runner %q?", model.RunnerId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err = req.Execute(); err != nil { + return fmt.Errorf("delete Intake Runner: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Deleting STACKIT Intake Runner", func() error { + _, err = wait.DeleteIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.RunnerId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake Runner deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Outputf("%s stackit Intake Runner %s \n", operationState, model.RunnerId) + + return nil + }, + } + return cmd +} + +// parseInput parses the command arguments and flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + runnerId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + RunnerId: runnerId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to delete an Intake Runner +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiDeleteIntakeRunnerRequest { + req := apiClient.DeleteIntakeRunner(ctx, model.ProjectId, model.Region, model.RunnerId) + return req +} diff --git a/internal/cmd/beta/intake/runner/delete/delete_test.go b/internal/cmd/beta/intake/runner/delete/delete_test.go new file mode 100644 index 000000000..ec5dc76f9 --- /dev/null +++ b/internal/cmd/beta/intake/runner/delete/delete_test.go @@ -0,0 +1,156 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + // testCtx is a dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient is a mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testRunnerId = uuid.NewString() +) + +// fixtureArgValues generates a slice of arguments for tests +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRunnerId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + RunnerId: testRunnerId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiDeleteIntakeRunnerRequest)) intake.ApiDeleteIntakeRunnerRequest { + request := testClient.DeleteIntakeRunner(testCtx, testProjectId, testRegion, testRunnerId) + 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 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "runner id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiDeleteIntakeRunnerRequest + }{ + { + 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/beta/intake/runner/describe/describe.go b/internal/cmd/beta/intake/runner/describe/describe.go new file mode 100644 index 000000000..47eedc386 --- /dev/null +++ b/internal/cmd/beta/intake/runner/describe/describe.go @@ -0,0 +1,118 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + runnerIdArg = "RUNNER_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + RunnerId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", runnerIdArg), + Short: "Shows details of an Intake Runner", + Long: "Shows details of an Intake Runner.", + Args: args.SingleArg(runnerIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an Intake Runner with ID "xxx"`, + `$ stackit beta intake runner describe xxx`), + examples.NewExample( + `Get details of an Intake Runner with ID "xxx" in JSON format`, + `$ stackit beta intake runner describe xxx --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API to get a single runner + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Intake Runner: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + runnerId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + RunnerId: runnerId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to get a single Intake Runner +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiGetIntakeRunnerRequest { + req := apiClient.GetIntakeRunner(ctx, model.ProjectId, model.Region, model.RunnerId) + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat string, runner *intake.IntakeRunnerResponse) error { + if runner == nil { + return fmt.Errorf("received nil runner, could not display details") + } + return p.OutputResult(outputFormat, runner, func() error { + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + table.AddRow("ID", runner.GetId()) + table.AddRow("Name", runner.GetDisplayName()) + table.AddRow("State", runner.GetState()) + table.AddRow("Created", runner.GetCreateTime()) + table.AddRow("Labels", runner.GetLabels()) + table.AddRow("Description", runner.GetDescription()) + table.AddRow("Max Message Size (KiB)", runner.GetMaxMessageSizeKiB()) + table.AddRow("Max Messages/Hour", runner.GetMaxMessagesPerHour()) + table.AddRow("Ingestion URI", runner.GetUri()) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/intake/runner/describe/describe_test.go b/internal/cmd/beta/intake/runner/describe/describe_test.go new file mode 100644 index 000000000..a9f6ff778 --- /dev/null +++ b/internal/cmd/beta/intake/runner/describe/describe_test.go @@ -0,0 +1,194 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testRunnerId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRunnerId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + RunnerId: testRunnerId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiGetIntakeRunnerRequest)) intake.ApiGetIntakeRunnerRequest { + request := testClient.GetIntakeRunner(testCtx, testProjectId, testRegion, testRunnerId) + 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 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "runner id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiGetIntakeRunnerRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + runner *intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", runner: &intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, runner: &intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, runner: &intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "nil runner", + args: args{runner: nil}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.runner); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/runner/list/list.go b/internal/cmd/beta/intake/runner/list/list.go new file mode 100644 index 000000000..81d1afa2d --- /dev/null +++ b/internal/cmd/beta/intake/runner/list/list.go @@ -0,0 +1,154 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +const ( + limitFlag = "limit" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +// NewCmd creates a new cobra command for listing Intake Runners +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Intake Runners", + Long: "Lists all Intake Runners for the current project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Intake Runners`, + `$ stackit beta intake runner list`), + examples.NewExample( + `List all Intake Runners in JSON format`, + `$ stackit beta intake runner list --output-format json`), + examples.NewExample( + `List up to 5 Intake Runners`, + `$ stackit beta intake runner list --limit 5`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Intake Runners: %w", err) + } + runners := resp.GetIntakeRunners() + + // Truncate output + if model.Limit != nil && len(runners) > int(*model.Limit) { + runners = runners[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(runners) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, runners) + }, + } + configureFlags(cmd) + return cmd +} + +// configureFlags adds the --limit flag to the command +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +// parseInput parses the command flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to list Intake Runners +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiListIntakeRunnersRequest { + req := apiClient.ListIntakeRunners(ctx, model.ProjectId, model.Region) + // Note: we do support API pagination, but for consistency with other services, we fetch all items and apply + // client-side limit. + // A more advanced implementation could use the --limit flag to set the API's PageSize. + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat, projectLabel string, runners []intake.IntakeRunnerResponse) error { + return p.OutputResult(outputFormat, runners, func() error { + if len(runners) == 0 { + p.Outputf("No intake runners found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + + table.SetHeader("ID", "NAME", "STATE") + for _, runner := range runners { + table.AddRow( + runner.GetId(), + runner.GetDisplayName(), + runner.GetState(), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/intake/runner/list/list_test.go b/internal/cmd/beta/intake/runner/list/list_test.go new file mode 100644 index 000000000..eed227010 --- /dev/null +++ b/internal/cmd/beta/intake/runner/list/list_test.go @@ -0,0 +1,199 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testLimit = int64(5) +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiListIntakeRunnersRequest)) intake.ApiListIntakeRunnersRequest { + request := testClient.ListIntakeRunners(testCtx, testProjectId, testRegion) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = strconv.FormatInt(testLimit, 10) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(testLimit) + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "limit is zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "limit is negative", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiListIntakeRunnersRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + runners []intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", runners: []intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, runners: []intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "empty slice", + args: args{runners: []intake.IntakeRunnerResponse{}}, + wantErr: false, + }, + { + name: "nil slice", + args: args{runners: nil}, + wantErr: false, + }, + { + name: "empty intake runner in slice", + args: args{ + runners: []intake.IntakeRunnerResponse{{}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, "dummy-projectlabel", tt.args.runners); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/runner/runner.go b/internal/cmd/beta/intake/runner/runner.go new file mode 100644 index 000000000..4c6d58607 --- /dev/null +++ b/internal/cmd/beta/intake/runner/runner.go @@ -0,0 +1,36 @@ +package runner + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "runner", + Short: "Provides functionality for Intake Runners", + Long: "Provides functionality for Intake Runners.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + // Pass the params down to each action command + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/beta/intake/runner/update/update.go b/internal/cmd/beta/intake/runner/update/update.go new file mode 100644 index 000000000..f59020818 --- /dev/null +++ b/internal/cmd/beta/intake/runner/update/update.go @@ -0,0 +1,178 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + runnerIdArg = "RUNNER_ID" +) + +const ( + displayNameFlag = "display-name" + maxMessageSizeKiBFlag = "max-message-size-kib" + maxMessagesPerHourFlag = "max-messages-per-hour" + descriptionFlag = "description" + labelFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + RunnerId string + DisplayName *string + MaxMessageSizeKiB *int64 + MaxMessagesPerHour *int64 + Description *string + Labels *map[string]string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", runnerIdArg), + Short: "Updates an Intake Runner", + Long: "Updates an Intake Runner. Only the specified fields are updated.", + Args: args.SingleArg(runnerIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of an Intake Runner with ID "xxx"`, + `$ stackit beta intake runner update xxx --display-name "new-runner-name"`), + examples.NewExample( + `Update the message capacity limits for an Intake Runner with ID "xxx"`, + `$ stackit beta intake runner update xxx --max-message-size-kib 1000 --max-messages-per-hour 10000`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update Intake Runner: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Updating STACKIT Intake Runner", func() error { + _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.RunnerId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake Runner update: %w", err) + } + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Int64(maxMessageSizeKiBFlag, 0, "Maximum message size in KiB. Note: Overall message capacity cannot be decreased.") + cmd.Flags().Int64(maxMessagesPerHourFlag, 0, "Maximum number of messages per hour. Note: Overall message capacity cannot be decreased.") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelFlag, nil, `Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2".`) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + runnerId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + RunnerId: runnerId, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + MaxMessageSizeKiB: flags.FlagToInt64Pointer(p, cmd, maxMessageSizeKiBFlag), + MaxMessagesPerHour: flags.FlagToInt64Pointer(p, cmd, maxMessagesPerHourFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + } + + if model.DisplayName == nil && model.MaxMessageSizeKiB == nil && model.MaxMessagesPerHour == nil && model.Description == nil && model.Labels == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiUpdateIntakeRunnerRequest { + req := apiClient.UpdateIntakeRunner(ctx, model.ProjectId, model.Region, model.RunnerId) + + payload := intake.UpdateIntakeRunnerPayload{} + if model.DisplayName != nil { + payload.DisplayName = model.DisplayName + } + if model.MaxMessageSizeKiB != nil { + payload.MaxMessageSizeKiB = model.MaxMessageSizeKiB + } + if model.MaxMessagesPerHour != nil { + payload.MaxMessagesPerHour = model.MaxMessagesPerHour + } + if model.Description != nil { + payload.Description = model.Description + } + if model.Labels != nil { + payload.Labels = model.Labels + } + + req = req.UpdateIntakeRunnerPayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *intake.IntakeRunnerResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered update of Intake Runner for project %q, but no runner ID was returned.\n", projectLabel) + return nil + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s Intake Runner for project %q. Runner ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/runner/update/update_test.go b/internal/cmd/beta/intake/runner/update/update_test.go new file mode 100644 index 000000000..3b1161ee1 --- /dev/null +++ b/internal/cmd/beta/intake/runner/update/update_test.go @@ -0,0 +1,279 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testRunnerId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRunnerId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: "new-runner-name", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + RunnerId: testRunnerId, + DisplayName: utils.Ptr("new-runner-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiUpdateIntakeRunnerRequest)) intake.ApiUpdateIntakeRunnerRequest { + request := testClient.UpdateIntakeRunner(testCtx, testProjectId, testRegion, testRunnerId) + payload := intake.UpdateIntakeRunnerPayload{ + DisplayName: utils.Ptr("new-runner-name"), + } + request = request.UpdateIntakeRunnerPayload(payload) + 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 update flags provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[maxMessageSizeKiBFlag] = "2048" + flagValues[maxMessagesPerHourFlag] = "10000" + flagValues[descriptionFlag] = "new description" + flagValues[labelFlag] = "env=prod,team=sre" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.MaxMessageSizeKiB = utils.Ptr(int64(2048)) + model.MaxMessagesPerHour = utils.Ptr(int64(10000)) + model.Description = utils.Ptr("new description") + model.Labels = utils.Ptr(map[string]string{"env": "prod", "team": "sre"}) + }), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiUpdateIntakeRunnerRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "update description and labels", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + model.Description = utils.Ptr("new-desc") + model.Labels = utils.Ptr(map[string]string{"key": "value"}) + }), + expectedRequest: fixtureRequest(func(request *intake.ApiUpdateIntakeRunnerRequest) { + payload := intake.UpdateIntakeRunnerPayload{ + Description: utils.Ptr("new-desc"), + Labels: utils.Ptr(map[string]string{"key": "value"}), + } + *request = (*request).UpdateIntakeRunnerPayload(payload) + }), + }, + { + description: "update all fields", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = utils.Ptr("another-name") + model.MaxMessageSizeKiB = utils.Ptr(int64(4096)) + model.MaxMessagesPerHour = utils.Ptr(int64(20000)) + model.Description = utils.Ptr("final-desc") + model.Labels = utils.Ptr(map[string]string{"a": "b"}) + }), + expectedRequest: fixtureRequest(func(request *intake.ApiUpdateIntakeRunnerRequest) { + payload := intake.UpdateIntakeRunnerPayload{ + DisplayName: utils.Ptr("another-name"), + MaxMessageSizeKiB: utils.Ptr(int64(4096)), + MaxMessagesPerHour: utils.Ptr(int64(20000)), + Description: utils.Ptr("final-desc"), + Labels: utils.Ptr(map[string]string{"a": "b"}), + } + *request = (*request).UpdateIntakeRunnerPayload(payload) + }), + }, + } + + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *intake.IntakeRunnerResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "default output - async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "my-project", + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &intake.IntakeRunnerResponse{Id: utils.Ptr("runner-id-123")}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: nil, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/update/update.go b/internal/cmd/beta/intake/update/update.go new file mode 100644 index 000000000..c21640e5d --- /dev/null +++ b/internal/cmd/beta/intake/update/update.go @@ -0,0 +1,267 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + intakeIdArg = "INTAKE_ID" + + // Top-level flags + displayNameFlag = "display-name" + runnerIdFlag = "runner-id" + descriptionFlag = "description" + labelsFlag = "labels" + + // Catalog flags + catalogURIFlag = "catalog-uri" + catalogWarehouseFlag = "catalog-warehouse" + catalogNamespaceFlag = "catalog-namespace" + catalogTableNameFlag = "catalog-table-name" + + // Auth flags + catalogAuthTypeFlag = "catalog-auth-type" + dremioTokenEndpointFlag = "dremio-token-endpoint" //nolint:gosec // false positive + dremioPatFlag = "dremio-pat" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + // Main + IntakeId string + DisplayName *string + RunnerId *string + Description *string + Labels *map[string]string + + // Catalog + CatalogURI *string + CatalogWarehouse *string + CatalogNamespace *string + CatalogTableName *string + + // Auth + CatalogAuthType *string + DremioTokenEndpoint *string + DremioToken *string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", intakeIdArg), + Short: "Updates an Intake", + Long: "Updates an Intake. Only the specified fields are updated.", + Args: args.SingleArg(intakeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of an Intake with ID "xxx"`, + `$ stackit beta intake update xxx --runner-id yyy --display-name new-intake-name`), + examples.NewExample( + `Update the catalog details for an Intake with ID "xxx"`, + `$ stackit beta intake update xxx --runner-id yyy --catalog-uri "http://new.uri" --catalog-warehouse "new-warehouse"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update Intake: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Updating STACKIT Intake Runner instance", func() error { + _, err = wait.CreateOrUpdateIntakeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Instance creation: %w", err) + } + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + // Top-level flags + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Var(flags.UUIDFlag(), runnerIdFlag, "The UUID of the Intake Runner to use") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelsFlag, nil, `Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2".`) + + // Catalog flags + cmd.Flags().String(catalogURIFlag, "", "The URI to the Iceberg catalog endpoint") + cmd.Flags().String(catalogWarehouseFlag, "", "The Iceberg warehouse to connect to") + cmd.Flags().String(catalogNamespaceFlag, "", "The namespace to which data shall be written") + cmd.Flags().String(catalogTableNameFlag, "", "The table name to identify the table in Iceberg") + + // Auth flags + cmd.Flags().String(catalogAuthTypeFlag, "", "Authentication type for the catalog (e.g., 'none', 'dremio')") + cmd.Flags().String(dremioTokenEndpointFlag, "", "Dremio OAuth 2.0 token endpoint URL") + cmd.Flags().String(dremioPatFlag, "", "Dremio personal access token") + + err := flags.MarkFlagsRequired(cmd, runnerIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + intakeId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: intakeId, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + RunnerId: flags.FlagToStringPointer(p, cmd, runnerIdFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + CatalogURI: flags.FlagToStringPointer(p, cmd, catalogURIFlag), + CatalogWarehouse: flags.FlagToStringPointer(p, cmd, catalogWarehouseFlag), + CatalogNamespace: flags.FlagToStringPointer(p, cmd, catalogNamespaceFlag), + CatalogTableName: flags.FlagToStringPointer(p, cmd, catalogTableNameFlag), + CatalogAuthType: flags.FlagToStringPointer(p, cmd, catalogAuthTypeFlag), + DremioTokenEndpoint: flags.FlagToStringPointer(p, cmd, dremioTokenEndpointFlag), + DremioToken: flags.FlagToStringPointer(p, cmd, dremioPatFlag), + } + + // Check if any optional flag was provided + if model.DisplayName == nil && model.Description == nil && model.Labels == nil && + model.CatalogURI == nil && model.CatalogWarehouse == nil && model.CatalogNamespace == nil && + model.CatalogTableName == nil && model.CatalogAuthType == nil && + model.DremioTokenEndpoint == nil && model.DremioToken == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiUpdateIntakeRequest { + req := apiClient.UpdateIntake(ctx, model.ProjectId, model.Region, model.IntakeId) + + payload := intake.UpdateIntakePayload{ + IntakeRunnerId: model.RunnerId, // This is required by the API + DisplayName: model.DisplayName, + Description: model.Description, + Labels: model.Labels, + } + + // Build catalog patch payload only if catalog-related flags are set + catalogPatch := &intake.IntakeCatalogPatch{} + catalogNeedsPatching := false + + if model.CatalogURI != nil { + catalogPatch.Uri = model.CatalogURI + catalogNeedsPatching = true + } + if model.CatalogWarehouse != nil { + catalogPatch.Warehouse = model.CatalogWarehouse + catalogNeedsPatching = true + } + if model.CatalogNamespace != nil { + catalogPatch.Namespace = model.CatalogNamespace + catalogNeedsPatching = true + } + if model.CatalogTableName != nil { + catalogPatch.TableName = model.CatalogTableName + catalogNeedsPatching = true + } + + // Build auth patch payload only if auth-related flags are set + authPatch := &intake.CatalogAuthPatch{} + authNeedsPatching := false + + if model.CatalogAuthType != nil { + authType := intake.CatalogAuthType(*model.CatalogAuthType) + authPatch.Type = &authType + authNeedsPatching = true + } + + dremioPatch := &intake.DremioAuthPatch{} + dremioNeedsPatching := false + if model.DremioTokenEndpoint != nil { + dremioPatch.TokenEndpoint = model.DremioTokenEndpoint + dremioNeedsPatching = true + } + if model.DremioToken != nil { + dremioPatch.PersonalAccessToken = model.DremioToken + dremioNeedsPatching = true + } + + if dremioNeedsPatching { + authPatch.Dremio = dremioPatch + authNeedsPatching = true + } + + if authNeedsPatching { + catalogPatch.Auth = authPatch + catalogNeedsPatching = true + } + + if catalogNeedsPatching { + payload.Catalog = catalogPatch + } + + req = req.UpdateIntakePayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *intake.IntakeResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Updated Intake for project %q, but no intake ID was returned.\n", projectLabel) + return nil + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s Intake for project %q. Intake ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/update/update_test.go b/internal/cmd/beta/intake/update/update_test.go new file mode 100644 index 000000000..94602f885 --- /dev/null +++ b/internal/cmd/beta/intake/update/update_test.go @@ -0,0 +1,284 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testRunnerId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{testIntakeId} + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + runnerIdFlag: testRunnerId, + displayNameFlag: "new-display-name", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + RunnerId: utils.Ptr(testRunnerId), + DisplayName: utils.Ptr("new-display-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +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 optional flags provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + runnerIdFlag: testRunnerId, + }, + isValid: false, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[descriptionFlag] = "new description" + flagValues[labelsFlag] = "env=prod,team=sre" + flagValues[catalogURIFlag] = "new-uri" + flagValues[catalogWarehouseFlag] = "new-warehouse" + flagValues[catalogNamespaceFlag] = "new-namespace" + flagValues[catalogTableNameFlag] = "new-table" + flagValues[catalogAuthTypeFlag] = "dremio" + flagValues[dremioTokenEndpointFlag] = "new-endpoint" + flagValues[dremioPatFlag] = "new-pat" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("new description") + model.Labels = utils.Ptr(map[string]string{"env": "prod", "team": "sre"}) + model.CatalogURI = utils.Ptr("new-uri") + model.CatalogWarehouse = utils.Ptr("new-warehouse") + model.CatalogNamespace = utils.Ptr("new-namespace") + model.CatalogTableName = utils.Ptr("new-table") + model.CatalogAuthType = utils.Ptr("dremio") + model.DremioTokenEndpoint = utils.Ptr("new-endpoint") + model.DremioToken = utils.Ptr("new-pat") + }), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "runner-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, runnerIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedReq intake.ApiUpdateIntakeRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: testClient.UpdateIntake(testCtx, testProjectId, testRegion, testIntakeId). + UpdateIntakePayload(intake.UpdateIntakePayload{ + IntakeRunnerId: utils.Ptr(testRunnerId), + DisplayName: utils.Ptr("new-display-name"), + }), + }, + { + description: "update description and catalog uri", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + model.Description = utils.Ptr("new-desc") + model.CatalogURI = utils.Ptr("new-uri") + }), + expectedReq: testClient.UpdateIntake(testCtx, testProjectId, testRegion, testIntakeId). + UpdateIntakePayload(intake.UpdateIntakePayload{ + IntakeRunnerId: utils.Ptr(testRunnerId), + Description: utils.Ptr("new-desc"), + Catalog: &intake.IntakeCatalogPatch{ + Uri: utils.Ptr("new-uri"), + }, + }), + }, + { + description: "update all fields", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = utils.Ptr("another-name") + model.Description = utils.Ptr("final-desc") + model.Labels = utils.Ptr(map[string]string{"a": "b"}) + model.CatalogURI = utils.Ptr("final-uri") + model.CatalogWarehouse = utils.Ptr("final-warehouse") + model.CatalogNamespace = utils.Ptr("final-namespace") + model.CatalogTableName = utils.Ptr("final-table") + model.CatalogAuthType = utils.Ptr("dremio") + model.DremioTokenEndpoint = utils.Ptr("final-endpoint") + model.DremioToken = utils.Ptr("final-token") + }), + expectedReq: testClient.UpdateIntake(testCtx, testProjectId, testRegion, testIntakeId). + UpdateIntakePayload(intake.UpdateIntakePayload{ + IntakeRunnerId: utils.Ptr(testRunnerId), + DisplayName: utils.Ptr("another-name"), + Description: utils.Ptr("final-desc"), + Labels: utils.Ptr(map[string]string{"a": "b"}), + Catalog: &intake.IntakeCatalogPatch{ + Uri: utils.Ptr("final-uri"), + Warehouse: utils.Ptr("final-warehouse"), + Namespace: utils.Ptr("final-namespace"), + TableName: utils.Ptr("final-table"), + Auth: &intake.CatalogAuthPatch{ + Type: utils.Ptr(intake.CatalogAuthType("dremio")), + Dremio: &intake.DremioAuthPatch{ + TokenEndpoint: utils.Ptr("final-endpoint"), + PersonalAccessToken: utils.Ptr("final-token"), + }, + }, + }, + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + intakeId string + resp *intake.IntakeResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", projectLabel: "my-project", intakeId: "intake-id-123", resp: &intake.IntakeResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, resp: &intake.IntakeResponse{Id: utils.Ptr("intake-id-123")}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, resp: &intake.IntakeResponse{Id: utils.Ptr("runner-id-123")}}, + wantErr: false, + }, + { + name: "nil response", + args: args{outputFormat: print.JSONOutputFormat, resp: nil}, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{outputFormat: "default", resp: nil}, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: tt.args.outputFormat}}, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/create/create.go b/internal/cmd/beta/intake/user/create/create.go new file mode 100644 index 000000000..436560060 --- /dev/null +++ b/internal/cmd/beta/intake/user/create/create.go @@ -0,0 +1,174 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + displayNameFlag = "display-name" + intakeIdFlag = "intake-id" + passwordFlag = "password" + userTypeFlag = "type" + descriptionFlag = "description" + labelsFlag = "labels" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + DisplayName *string + IntakeId *string + Password *string + UserType *string + Description *string + Labels *map[string]string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new Intake User", + Long: "Creates a new Intake User for a specific Intake.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new Intake User with required parameters`, + `$ stackit beta intake user create --display-name intake-user --intake-id xxx --password "SuperSafepass123\!"`), + examples.NewExample( + `Create a new Intake User for the dead-letter queue with labels`, + `$ stackit beta intake user create --display-name dlq-user --intake-id xxx --password "SuperSafepass123\!" --type dead-letter --labels "env=prod"`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create an Intake User for project %q?", projectLabel) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Intake User: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Creating STACKIT Intake User", func() error { + _, err = wait.CreateOrUpdateIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *model.IntakeId, resp.GetId()).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake User creation: %w", err) + } + } + + return outputResult(p.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "The UUID of the Intake to associate the user with") + cmd.Flags().String(passwordFlag, "", "Password for the user. Must contain lower, upper, number, and special characters (min 12 chars)") + cmd.Flags().String(userTypeFlag, string(intake.USERTYPE_INTAKE), "Type of user. One of 'intake' (default) or 'dead-letter'") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringToString(labelsFlag, nil, "Labels in key=value format, separated by commas") + + err := flags.MarkFlagsRequired(cmd, displayNameFlag, intakeIdFlag, passwordFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + IntakeId: flags.FlagToStringPointer(p, cmd, intakeIdFlag), + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + UserType: flags.FlagToStringPointer(p, cmd, userTypeFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiCreateIntakeUserRequest { + req := apiClient.CreateIntakeUser(ctx, model.ProjectId, model.Region, *model.IntakeId) + + var userType *intake.UserType + if model.UserType != nil { + userType = utils.Ptr(intake.UserType(*model.UserType)) + } + + payload := intake.CreateIntakeUserPayload{ + DisplayName: model.DisplayName, + Password: model.Password, + Type: userType, + Description: model.Description, + Labels: model.Labels, + } + + req = req.CreateIntakeUserPayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *intake.IntakeUserResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered creation of Intake User for project %q, but no user ID was returned.\n", projectLabel) + return nil + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s Intake User for project %q. User ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/create/create_test.go b/internal/cmd/beta/intake/user/create/create_test.go new file mode 100644 index 000000000..4d28c92c7 --- /dev/null +++ b/internal/cmd/beta/intake/user/create/create_test.go @@ -0,0 +1,294 @@ +package create + +import ( + "context" + "testing" + + "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/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testDisplayName = "testuser" + testPassword = "Secret12345!" + testUserType = "intake" + testDescription = "This is a test user" + testLabelsString = "env=test,team=dev" +) + +var ( + // testCtx dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + + testLabels = map[string]string{"env": "test", "team": "dev"} +) + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + intakeIdFlag: testIntakeId, + passwordFlag: testPassword, + userTypeFlag: testUserType, + descriptionFlag: testDescription, + labelsFlag: testLabelsString, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: utils.Ptr(testDisplayName), + IntakeId: utils.Ptr(testIntakeId), + Password: utils.Ptr(testPassword), + UserType: utils.Ptr(testUserType), + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureCreatePayload generates a CreateIntakeUserPayload for tests +func fixtureCreatePayload(mods ...func(payload *intake.CreateIntakeUserPayload)) intake.CreateIntakeUserPayload { + userType := intake.UserType(testUserType) + payload := intake.CreateIntakeUserPayload{ + DisplayName: utils.Ptr(testDisplayName), + Password: utils.Ptr(testPassword), + Type: &userType, + Description: utils.Ptr(testDescription), + Labels: utils.Ptr(testLabels), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiCreateIntakeUserRequest)) intake.ApiCreateIntakeUserRequest { + request := testClient.CreateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId) + request = request.CreateIntakeUserPayload(fixtureCreatePayload()) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "display name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "password missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, passwordFlag) + }), + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + intakeIdFlag: testIntakeId, + passwordFlag: testPassword, + userTypeFlag: testUserType, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + // UserType has a default value in the command definition, so it should still be populated + model.UserType = utils.Ptr(string(intake.USERTYPE_INTAKE)) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiCreateIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optionals", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.Labels = nil + model.UserType = nil + }), + expectedRequest: fixtureRequest(func(request *intake.ApiCreateIntakeUserRequest) { + *request = (*request).CreateIntakeUserPayload(fixtureCreatePayload(func(payload *intake.CreateIntakeUserPayload) { + payload.Description = nil + payload.Labels = nil + payload.Type = nil + })) + }), + }, + } + + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + projectLabel string + resp *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{ + model: fixtureInputModel(), + projectLabel: "my-project", + resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, + }, + wantErr: false, + }, + { + name: "default output - async", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.Async = true + }), + projectLabel: "my-project", + resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}, + }, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{ + model: fixtureInputModel(), + resp: nil, + }, + wantErr: false, + }, + { + name: "nil response - json output", + args: args{ + model: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = print.JSONOutputFormat + }), + resp: nil, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/delete/delete.go b/internal/cmd/beta/intake/user/delete/delete.go new file mode 100644 index 000000000..f6d43b51d --- /dev/null +++ b/internal/cmd/beta/intake/user/delete/delete.go @@ -0,0 +1,126 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + userIdArg = "USER_ID" + intakeIdFlag = "intake-id" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string +} + +// NewCmd creates a new cobra command for deleting an Intake User +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", userIdArg), + Short: "Deletes an Intake User", + Long: "Deletes an Intake User.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete an Intake User with ID "xxx" for Intake "yyy"`, + `$ stackit beta intake user delete xxx --intake-id yyy`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to delete Intake User %q?", model.UserId) + err = p.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err = req.Execute(); err != nil { + return fmt.Errorf("delete Intake User: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Deleting STACKIT Intake User", func() error { + _, err = wait.DeleteIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId, model.UserId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for STACKIT Intake User deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + p.Printer.Outputf("%s STACKIT Intake User %s\n", operationState, model.UserId) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +// parseInput parses the command arguments and flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringValue(p, cmd, intakeIdFlag), + UserId: userId, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to delete an Intake User +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiDeleteIntakeUserRequest { + req := apiClient.DeleteIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + return req +} diff --git a/internal/cmd/beta/intake/user/delete/delete_test.go b/internal/cmd/beta/intake/user/delete/delete_test.go new file mode 100644 index 000000000..9aa042552 --- /dev/null +++ b/internal/cmd/beta/intake/user/delete/delete_test.go @@ -0,0 +1,175 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +// Define a unique key for the context to avoid collisions +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + // testCtx is a dummy context for testing purposes + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + // testClient is a mock API client + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testUserId = uuid.NewString() +) + +// fixtureArgValues generates a slice of arguments for tests +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// fixtureFlagValues generates a map of flag values for tests +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// fixtureInputModel generates an input model for tests +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// fixtureRequest generates an API request for tests +func fixtureRequest(mods ...func(request *intake.ApiDeleteIntakeUserRequest)) intake.ApiDeleteIntakeUserRequest { + request := testClient.DeleteIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, 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 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[intakeIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "user id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiDeleteIntakeUserRequest + }{ + { + 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/beta/intake/user/describe/describe.go b/internal/cmd/beta/intake/user/describe/describe.go new file mode 100644 index 000000000..5a12896aa --- /dev/null +++ b/internal/cmd/beta/intake/user/describe/describe.go @@ -0,0 +1,135 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + userIdArg = "USER_ID" + intakeIdFlag = "intake-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", userIdArg), + Short: "Shows details of an Intake User", + Long: "Shows details of an Intake User.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of an Intake User with ID "xxx" for Intake "yyy"`, + `$ stackit beta intake user describe xxx --intake-id yyy`), + examples.NewExample( + `Get details of an Intake User with ID "xxx" in JSON format`, + `$ stackit beta intake user describe xxx --intake-id yyy --output-format json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Intake User: %w", err) + } + + return outputResult(p.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringValue(p, cmd, intakeIdFlag), + UserId: userId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiGetIntakeUserRequest { + req := apiClient.GetIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, user *intake.IntakeUserResponse) error { + if user == nil { + return fmt.Errorf("received nil response, could not display details") + } + + return p.OutputResult(outputFormat, user, func() error { + table := tables.NewTable() + table.SetHeader("Attribute", "Value") + + table.AddRow("ID", user.GetId()) + table.AddRow("Name", user.GetDisplayName()) + table.AddRow("State", user.GetState()) + + if user.Type != nil { + table.AddRow("Type", *user.Type) + } + + table.AddRow("Username", user.GetUser()) + table.AddRow("Created", user.GetCreateTime()) + table.AddRow("Labels", user.GetLabels()) + + if description := user.GetDescription(); description != "" { + table.AddRow("Description", description) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/describe/describe_test.go b/internal/cmd/beta/intake/user/describe/describe_test.go new file mode 100644 index 000000000..b3aacd8bb --- /dev/null +++ b/internal/cmd/beta/intake/user/describe/describe_test.go @@ -0,0 +1,212 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + 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{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiGetIntakeUserRequest)) intake.ApiGetIntakeUserRequest { + request := testClient.GetIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, 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 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[intakeIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "user id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiGetIntakeUserRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + user *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", user: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, user: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, user: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "nil user", + args: args{user: nil}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.user); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/list/list.go b/internal/cmd/beta/intake/user/list/list.go new file mode 100644 index 000000000..3c1f57520 --- /dev/null +++ b/internal/cmd/beta/intake/user/list/list.go @@ -0,0 +1,158 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + intakeIdFlag = "intake-id" + limitFlag = "limit" +) + +// inputModel struct holds all the input parameters for the command +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId *string + Limit *int64 +} + +// NewCmd creates a new cobra command for listing Intake Users +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Intake Users", + Long: "Lists all Intake Users for a specific Intake.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all users for an Intake`, + `$ stackit beta intake user list --intake-id xxx`), + examples.NewExample( + `List all users for an Intake in JSON format`, + `$ stackit beta intake user list --intake-id xxx --output-format json`), + examples.NewExample( + `List up to 5 users for an Intake`, + `$ stackit beta intake user list --intake-id xxx --limit 5`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list Intake Users: %w", err) + } + users := resp.GetIntakeUsers() + + // Truncate output + if model.Limit != nil && len(users) > int(*model.Limit) { + users = users[:*model.Limit] + } + + projectLabel := model.ProjectId + if len(users) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, p.Printer, p.CliVersion, cmd) + if err != nil { + p.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(p.Printer, model.OutputFormat, projectLabel, *model.IntakeId, users) + }, + } + configureFlags(cmd) + return cmd +} + +// configureFlags adds the flags to the command +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +// parseInput parses the command flags into a standardized model +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringPointer(p, cmd, intakeIdFlag), + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +// buildRequest creates the API request to list Intake Users +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiListIntakeUsersRequest { + req := apiClient.ListIntakeUsers(ctx, model.ProjectId, model.Region, *model.IntakeId) + return req +} + +// outputResult formats the API response and prints it to the console +func outputResult(p *print.Printer, outputFormat, projectLabel, intakeId string, users []intake.IntakeUserResponse) error { + return p.OutputResult(outputFormat, users, func() error { + if len(users) == 0 { + p.Outputf("No intake users found for intake %q in project %q\n", intakeId, projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "DISPLAY NAME", "TYPE", "STATE") + for _, user := range users { + table.AddRow( + user.GetId(), + user.GetDisplayName(), + utils.PtrString(user.Type), + user.GetState(), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/list/list_test.go b/internal/cmd/beta/intake/user/list/list_test.go new file mode 100644 index 000000000..f83246a44 --- /dev/null +++ b/internal/cmd/beta/intake/user/list/list_test.go @@ -0,0 +1,226 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "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/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + testLimit = int64(5) +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: utils.Ptr(testIntakeId), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiListIntakeUsersRequest)) intake.ApiListIntakeUsersRequest { + request := testClient.ListIntakeUsers(testCtx, testProjectId, testRegion, testIntakeId) + 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 limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = strconv.FormatInt(testLimit, 10) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(testLimit) + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + { + description: "intake id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[intakeIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit is zero", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "limit is negative", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, func(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + return parseInput(p, cmd) + }, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest intake.ApiListIntakeUsersRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + intakeId string + users []intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", intakeId: testIntakeId, users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, intakeId: testIntakeId, users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "empty slice", + args: args{intakeId: testIntakeId, users: []intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "nil slice", + args: args{intakeId: testIntakeId, users: nil}, + wantErr: false, + }, + { + name: "empty user in slice", + args: args{ + intakeId: testIntakeId, + users: []intake.IntakeUserResponse{{}}, + }, + wantErr: false, + }, + { + name: "with project label", + args: args{ + projectLabel: "my-project", + intakeId: testIntakeId, + users: []intake.IntakeUserResponse{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.intakeId, tt.args.users); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/update/update.go b/internal/cmd/beta/intake/user/update/update.go new file mode 100644 index 000000000..a2b1b881c --- /dev/null +++ b/internal/cmd/beta/intake/user/update/update.go @@ -0,0 +1,170 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + "github.com/stackitcloud/stackit-sdk-go/services/intake/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/intake/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + userIdArg = "USER_ID" + + intakeIdFlag = "intake-id" + displayNameFlag = "display-name" + descriptionFlag = "description" + passwordFlag = "password" + userTypeFlag = "type" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IntakeId string + UserId string + DisplayName *string + Description *string + Password *string + UserType *string + Labels *map[string]string +} + +func NewCmd(p *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", userIdArg), + Short: "Updates an Intake User", + Long: "Updates an Intake User. Only the specified fields are updated.", + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of an Intake User`, + `$ stackit beta intake user update xxx --intake-id yyy --display-name "new-user-name"`), + examples.NewExample( + `Update the password and description for an Intake User`, + `$ stackit beta intake user update xxx --intake-id yyy --password "NewSecret123\!" --description "Updated description"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p.Printer, p.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update Intake User: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(p.Printer, "Updating STACKIT Intake User", func() error { + _, err = wait.CreateOrUpdateIntakeUserWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.IntakeId, model.UserId).WaitWithContext(ctx) + return err + }) + + if err != nil { + return fmt.Errorf("wait for STACKIT Intake User update: %w", err) + } + } + + return outputResult(p.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), intakeIdFlag, "Intake ID") + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().String(passwordFlag, "", "Password for the user. Must contain lower, upper, number, and special characters (min 12 chars)") + cmd.Flags().String(userTypeFlag, "", "Type of user. One of 'intake' or 'dead-letter'") + cmd.Flags().StringToString(labelsFlag, nil, `Labels in key=value format, separated by commas. Example: --labels "key1=value1,key2=value2".`) + + err := flags.MarkFlagsRequired(cmd, intakeIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := &inputModel{ + GlobalFlagModel: globalFlags, + IntakeId: flags.FlagToStringValue(p, cmd, intakeIdFlag), + UserId: userId, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Password: flags.FlagToStringPointer(p, cmd, passwordFlag), + UserType: flags.FlagToStringPointer(p, cmd, userTypeFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), + } + + if model.DisplayName == nil && model.Description == nil && model.Password == nil && model.UserType == nil && model.Labels == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *intake.APIClient) intake.ApiUpdateIntakeUserRequest { + req := apiClient.UpdateIntakeUser(ctx, model.ProjectId, model.Region, model.IntakeId, model.UserId) + + payload := intake.UpdateIntakeUserPayload{ + DisplayName: model.DisplayName, + Description: model.Description, + Password: model.Password, + Labels: model.Labels, + } + + if model.UserType != nil { + userType := intake.UserType(*model.UserType) + payload.Type = &userType + } + + req = req.UpdateIntakeUserPayload(payload) + return req +} + +func outputResult(p *print.Printer, model *inputModel, resp *intake.IntakeUserResponse) error { + return p.OutputResult(model.OutputFormat, resp, func() error { + if resp == nil { + p.Outputf("Triggered update of Intake User for intake %q, but no user ID was returned.\n", model.IntakeId) + return nil + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + p.Outputf("%s Intake User for intake %q. User ID: %s\n", operationState, model.IntakeId, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/intake/user/update/update_test.go b/internal/cmd/beta/intake/user/update/update_test.go new file mode 100644 index 000000000..f887ac4a0 --- /dev/null +++ b/internal/cmd/beta/intake/user/update/update_test.go @@ -0,0 +1,260 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &intake.APIClient{} + testProjectId = uuid.NewString() + testIntakeId = uuid.NewString() + 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{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + displayNameFlag: "new-display-name", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + IntakeId: testIntakeId, + UserId: testUserId, + DisplayName: utils.Ptr("new-display-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *intake.ApiUpdateIntakeUserRequest)) intake.ApiUpdateIntakeUserRequest { + request := testClient.UpdateIntakeUser(testCtx, testProjectId, testRegion, testIntakeId, testUserId) + payload := intake.UpdateIntakeUserPayload{ + DisplayName: utils.Ptr("new-display-name"), + } + request = request.UpdateIntakeUserPayload(payload) + 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 optional flags provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + intakeIdFlag: testIntakeId, + }, + isValid: false, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[descriptionFlag] = "new description" + flagValues[labelsFlag] = "env=prod,team=sre" + flagValues[userTypeFlag] = "dead-letter" + flagValues[passwordFlag] = "NewSecret123!" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = utils.Ptr("new description") + model.Labels = utils.Ptr(map[string]string{"env": "prod", "team": "sre"}) + model.UserType = utils.Ptr("dead-letter") + model.Password = utils.Ptr("NewSecret123!") + }), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "intake-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, intakeIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedReq intake.ApiUpdateIntakeUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedReq: fixtureRequest(), + }, + { + description: "update description", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + model.Description = utils.Ptr("new-desc") + }), + expectedReq: fixtureRequest(func(request *intake.ApiUpdateIntakeUserRequest) { + payload := intake.UpdateIntakeUserPayload{ + Description: utils.Ptr("new-desc"), + } + *request = (*request).UpdateIntakeUserPayload(payload) + }), + }, + { + description: "update all fields", + model: fixtureInputModel(func(model *inputModel) { + model.DisplayName = utils.Ptr("another-name") + model.Description = utils.Ptr("final-desc") + model.Labels = utils.Ptr(map[string]string{"a": "b"}) + model.UserType = utils.Ptr("dead-letter") + model.Password = utils.Ptr("Secret123!") + }), + expectedReq: fixtureRequest(func(request *intake.ApiUpdateIntakeUserRequest) { + userType := intake.UserType("dead-letter") + payload := intake.UpdateIntakeUserPayload{ + DisplayName: utils.Ptr("another-name"), + Description: utils.Ptr("final-desc"), + Labels: utils.Ptr(map[string]string{"a": "b"}), + Type: &userType, + Password: utils.Ptr("Secret123!"), + } + *request = (*request).UpdateIntakeUserPayload(payload) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedReq, request, + cmp.AllowUnexported(request), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + intakeId string + resp *intake.IntakeUserResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", projectLabel: "my-project", intakeId: "intake-id-123", resp: &intake.IntakeUserResponse{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, resp: &intake.IntakeUserResponse{Id: utils.Ptr("user-id-123")}}, + wantErr: false, + }, + { + name: "nil response", + args: args{outputFormat: print.JSONOutputFormat, resp: nil}, + wantErr: false, + }, + { + name: "nil response - default output", + args: args{outputFormat: "default", resp: nil}, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: tt.args.outputFormat}, IntakeId: tt.args.intakeId}, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/intake/user/user.go b/internal/cmd/beta/intake/user/user.go new file mode 100644 index 000000000..6e53c457b --- /dev/null +++ b/internal/cmd/beta/intake/user/user.go @@ -0,0 +1,36 @@ +package user + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/user/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Provides functionality for Intake Users", + Long: "Provides functionality for Intake Users.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + // Pass the params down to each action command + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/beta/sfs/export-policy/create/create.go b/internal/cmd/beta/sfs/export-policy/create/create.go new file mode 100644 index 000000000..e640862df --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/create/create.go @@ -0,0 +1,150 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + nameFlag = "name" + rulesFlag = "rules" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string + Rules *[]sfs.CreateShareExportPolicyRequestRule +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a export policy", + Long: "Creates a export policy.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new export policy with name "EXPORT_POLICY_NAME"`, + "$ stackit beta sfs export-policy create --name EXPORT_POLICY_NAME", + ), + examples.NewExample( + `Create a new export policy with name "EXPORT_POLICY_NAME" and rules from file "./rules.json"`, + "$ stackit beta sfs export-policy create --name EXPORT_POLICY_NAME --rules @./rules.json", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a export policy for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create export policy: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Export policy name") + cmd.Flags().Var(flags.ReadFromFileFlag(), rulesFlag, "Rules of the export policy (format: json)") + + err := flags.MarkFlagsRequired(cmd, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + rulesString := flags.FlagToStringPointer(p, cmd, rulesFlag) + var rules *[]sfs.CreateShareExportPolicyRequestRule + if rulesString != nil && *rulesString != "" { + var r []sfs.CreateShareExportPolicyRequestRule + err := json.Unmarshal([]byte(*rulesString), &r) + if err != nil { + return nil, fmt.Errorf("could not parse rules: %w", err) + } + rules = &r + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + Rules: rules, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiCreateShareExportPolicyRequest { + req := apiClient.CreateShareExportPolicy(ctx, model.ProjectId, model.Region) + req = req.CreateShareExportPolicyPayload( + sfs.CreateShareExportPolicyPayload{ + Name: utils.Ptr(model.Name), + Rules: model.Rules, + }, + ) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, item *sfs.CreateShareExportPolicyResponse) error { + return p.OutputResult(outputFormat, item, func() error { + if item == nil || item.ShareExportPolicy == nil { + return fmt.Errorf("no export policy found") + } + p.Outputf( + "Created export policy %q for project %q.\nExport policy ID: %s\n", + utils.PtrString(item.ShareExportPolicy.Name), + projectLabel, + utils.PtrString(item.ShareExportPolicy.Id), + ) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/export-policy/create/create_test.go b/internal/cmd/beta/sfs/export-policy/create/create_test.go new file mode 100644 index 000000000..d019f6bd4 --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/create/create_test.go @@ -0,0 +1,213 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" +var testName = "test-name" +var testRulesString = "[]" +var testRules = &[]sfs.CreateShareExportPolicyRequestRule{} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + nameFlag: testName, + rulesFlag: testRulesString, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Name: testName, + Rules: testRules, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiCreateShareExportPolicyRequest)) sfs.ApiCreateShareExportPolicyRequest { + request := testClient.CreateShareExportPolicy(testCtx, testProjectId, testRegion) + request = request.CreateShareExportPolicyPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *sfs.CreateShareExportPolicyPayload)) sfs.CreateShareExportPolicyPayload { + payload := sfs.CreateShareExportPolicyPayload{ + Name: utils.Ptr(testName), + Rules: testRules, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "required only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, rulesFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Rules = nil + }), + }, + { + description: "required read rules from file", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[rulesFlag] = "@../test-files/rules-example.json" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Rules = &[]sfs.CreateShareExportPolicyRequestRule{ + { + Description: sfs.NewNullableString( + utils.Ptr("first rule"), + ), + IpAcl: utils.Ptr([]string{"192.168.2.0/24"}), + Order: utils.Ptr(int64(1)), + SetUuid: utils.Ptr(true), + SuperUser: utils.Ptr(false), + }, + { + IpAcl: utils.Ptr([]string{"192.168.2.0/24", "127.0.0.1/32"}), + Order: utils.Ptr(int64(2)), + ReadOnly: utils.Ptr(true), + }, + } + }), + }, + } + opts := []testutils.TestingOption{ + testutils.WithCmpOptions(cmp.AllowUnexported(sfs.NullableString{})), + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithOptions(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, nil, tt.isValid, opts) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiCreateShareExportPolicyRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + exportPolicy *sfs.CreateShareExportPolicyResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "set empty export policy", + args: args{ + exportPolicy: &sfs.CreateShareExportPolicyResponse{}, + }, + wantErr: true, + }, + { + name: "set empty export policy", + args: args{ + exportPolicy: &sfs.CreateShareExportPolicyResponse{ + ShareExportPolicy: &sfs.CreateShareExportPolicyResponseShareExportPolicy{}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.exportPolicy); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/export-policy/delete/delete.go b/internal/cmd/beta/sfs/export-policy/delete/delete.go new file mode 100644 index 000000000..a02a994fc --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/delete/delete.go @@ -0,0 +1,99 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const exportPolicyIdArg = "EXPORT_POLICY_ID" + +type inputModel struct { + *globalflags.GlobalFlagModel + ExportPolicyId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", exportPolicyIdArg), + Short: "Deletes a export policy", + Long: "Deletes a export policy.", + Args: args.SingleArg(exportPolicyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a export policy with ID "xxx"`, + "$ stackit beta sfs export-policy delete xxx", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + exportPolicyLabel, err := sfsUtils.GetExportPolicyName(ctx, apiClient, model.ProjectId, model.Region, model.ExportPolicyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get export policy name: %v", err) + exportPolicyLabel = model.ExportPolicyId + } else if exportPolicyLabel == "" { + exportPolicyLabel = model.ExportPolicyId + } + + prompt := fmt.Sprintf("Are you sure you want to delete export policy %q? (This cannot be undone)", exportPolicyLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete export policy: %w", err) + } + + params.Printer.Outputf("Deleted export policy %q\n", exportPolicyLabel) + return nil + }, + } + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiDeleteShareExportPolicyRequest { + return apiClient.DeleteShareExportPolicy(ctx, model.ProjectId, model.Region, model.ExportPolicyId) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + exportPolicyId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ExportPolicyId: exportPolicyId, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/beta/sfs/export-policy/delete/delete_test.go b/internal/cmd/beta/sfs/export-policy/delete/delete_test.go new file mode 100644 index 000000000..77ea7870a --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/delete/delete_test.go @@ -0,0 +1,175 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" +var testExportPolicyId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testExportPolicyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ExportPolicyId: testExportPolicyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiDeleteShareExportPolicyRequest)) sfs.ApiDeleteShareExportPolicyRequest { + request := testClient.DeleteShareExportPolicy(testCtx, testProjectId, testRegion, testExportPolicyId) + 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: "export policy id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "export policy id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiDeleteShareExportPolicyRequest + }{ + { + 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/beta/sfs/export-policy/describe/describe.go b/internal/cmd/beta/sfs/export-policy/describe/describe.go new file mode 100644 index 000000000..4cbed0c22 --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/describe/describe.go @@ -0,0 +1,152 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const exportPolicyIdArg = "EXPORT_POLICY_ID" + +type inputModel struct { + *globalflags.GlobalFlagModel + ExportPolicyId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", exportPolicyIdArg), + Short: "Shows details of a export policy", + Long: "Shows details of a export policy.", + Args: args.SingleArg(exportPolicyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a export policy with ID "xxx"`, + "$ stackit beta sfs export-policy describe xxx", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read export policy: %w", err) + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, model.ExportPolicyId, projectLabel, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + exportPolicyId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ExportPolicyId: exportPolicyId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiGetShareExportPolicyRequest { + return apiClient.GetShareExportPolicy(ctx, model.ProjectId, model.Region, model.ExportPolicyId) +} + +func outputResult(p *print.Printer, outputFormat, exportPolicyId, projectLabel string, exportPolicy *sfs.GetShareExportPolicyResponse) error { + return p.OutputResult(outputFormat, exportPolicy, func() error { + if exportPolicy == nil || exportPolicy.ShareExportPolicy == nil { + p.Outputf("Export policy %q not found in project %q", exportPolicyId, projectLabel) + return nil + } + + var content []tables.Table + + table := tables.NewTable() + table.SetTitle("Export Policy") + policy := exportPolicy.ShareExportPolicy + + table.AddRow("ID", utils.PtrString(policy.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(policy.Name)) + table.AddSeparator() + table.AddRow("SHARES USING EXPORT POLICY", utils.PtrString(policy.SharesUsingExportPolicy)) + table.AddSeparator() + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(policy.CreatedAt)) + + content = append(content, table) + + if policy.Rules != nil && len(*policy.Rules) > 0 { + rulesTable := tables.NewTable() + rulesTable.SetTitle("Rules") + + rulesTable.SetHeader("ID", "ORDER", "DESCRIPTION", "IP ACL", "READ ONLY", "SET UUID", "SUPER USER", "CREATED AT") + + for _, rule := range *policy.Rules { + var description string + if rule.Description != nil { + description = utils.PtrString(rule.Description.Get()) + } + rulesTable.AddRow( + utils.PtrString(rule.Id), + utils.PtrString(rule.Order), + description, + utils.JoinStringPtr(rule.IpAcl, ", "), + utils.PtrString(rule.ReadOnly), + utils.PtrString(rule.SetUuid), + utils.PtrString(rule.SuperUser), + utils.ConvertTimePToDateTimeString(rule.CreatedAt), + ) + rulesTable.AddSeparator() + } + + content = append(content, rulesTable) + } + + if err := tables.DisplayTables(p, content); err != nil { + return fmt.Errorf("render tables: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/sfs/export-policy/describe/describe_test.go b/internal/cmd/beta/sfs/export-policy/describe/describe_test.go new file mode 100644 index 000000000..6b991a0b0 --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/describe/describe_test.go @@ -0,0 +1,222 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" +var testExportPolicyId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testExportPolicyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ExportPolicyId: testExportPolicyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiGetShareExportPolicyRequest)) sfs.ApiGetShareExportPolicyRequest { + request := testClient.GetShareExportPolicy(testCtx, testProjectId, testRegion, testExportPolicyId) + 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: "export policy id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "export policy id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiGetShareExportPolicyRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + exportPolicyId string + projectLabel string + exportPolicy *sfs.GetShareExportPolicyResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty export policy", + args: args{ + exportPolicy: &sfs.GetShareExportPolicyResponse{}, + }, + wantErr: false, + }, + { + name: "set empty export policy", + args: args{ + exportPolicy: &sfs.GetShareExportPolicyResponse{ + ShareExportPolicy: &sfs.GetShareExportPolicyResponseShareExportPolicy{}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.exportPolicyId, tt.args.projectLabel, tt.args.exportPolicy); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/export-policy/export-policy.go b/internal/cmd/beta/sfs/export-policy/export-policy.go new file mode 100644 index 000000000..221b4f1f3 --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/export-policy.go @@ -0,0 +1,33 @@ +package exportpolicy + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/export-policy/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/export-policy/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/export-policy/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/export-policy/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/export-policy/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "export-policy", + Short: "Provides functionality for SFS export policies", + Long: "Provides functionality for SFS export policies.", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/beta/sfs/export-policy/list/list.go b/internal/cmd/beta/sfs/export-policy/list/list.go new file mode 100644 index 000000000..186f07106 --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/list/list.go @@ -0,0 +1,148 @@ +package list + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all export policies of a project", + Long: "Lists all export policies of a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all export policies`, + "$ stackit beta sfs export-policy list", + ), + examples.NewExample( + `List up to 10 export policies`, + "$ stackit beta sfs export-policy list --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list export policies: %w", err) + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + // Truncate output + items := utils.GetSliceFromPointer(resp.ShareExportPolicies) + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, items) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiListShareExportPoliciesRequest { + return apiClient.ListShareExportPolicies(ctx, model.ProjectId, model.Region) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, exportPolicies []sfs.ShareExportPolicy) error { + return p.OutputResult(outputFormat, exportPolicies, func() error { + if len(exportPolicies) == 0 { + p.Outputf("No export policies found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "AMOUNT RULES", "SHARES USING THIS EXPORT POLICY", "CREATED AT") + + for _, exportPolicy := range exportPolicies { + amountRules := "-" + if exportPolicy.Rules != nil { + amountRules = strconv.Itoa(len(*exportPolicy.Rules)) + } + table.AddRow( + utils.PtrString(exportPolicy.Id), + utils.PtrString(exportPolicy.Name), + amountRules, + utils.PtrString(exportPolicy.SharesUsingExportPolicy), + utils.ConvertTimePToDateTimeString(exportPolicy.CreatedAt), + ) + } + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/export-policy/list/list_test.go b/internal/cmd/beta/sfs/export-policy/list/list_test.go new file mode 100644 index 000000000..d9e463a46 --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/list/list_test.go @@ -0,0 +1,177 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + limitFlag: strconv.Itoa(10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiListShareExportPoliciesRequest)) sfs.ApiListShareExportPoliciesRequest { + request := testClient.ListShareExportPolicies(testCtx, testProjectId, testRegion) + 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", + 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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiListShareExportPoliciesRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + exportPolicies []sfs.ShareExportPolicy + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty export policy", + args: args{ + exportPolicies: []sfs.ShareExportPolicy{ + {}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.exportPolicies); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/export-policy/test-files/rules-example.json b/internal/cmd/beta/sfs/export-policy/test-files/rules-example.json new file mode 100644 index 000000000..57b2cbcb1 --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/test-files/rules-example.json @@ -0,0 +1,14 @@ +[ + { + "description": "first rule", + "ipAcl": ["192.168.2.0/24"], + "order": 1, + "superUser": false, + "setUuid": true + }, + { + "ipAcl": ["192.168.2.0/24", "127.0.0.1/32"], + "order": 2, + "readonly": true + } +] \ No newline at end of file diff --git a/internal/cmd/beta/sfs/export-policy/update/update.go b/internal/cmd/beta/sfs/export-policy/update/update.go new file mode 100644 index 000000000..5b0e1457a --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/update/update.go @@ -0,0 +1,171 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + exportPolicyArg = "EXPORT_POLICY_ID" + + rulesFlag = "rules" + removeRulesFlag = "remove-rules" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ExportPolicyId string + Rules *[]sfs.UpdateShareExportPolicyBodyRule +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", exportPolicyArg), + Short: "Updates a export policy", + Long: "Updates a export policy.", + Args: args.SingleArg(exportPolicyArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update a export policy with ID "xxx" and with rules from file "./rules.json"`, + "$ stackit beta sfs export-policy update xxx --rules @./rules.json", + ), + examples.NewExample( + `Update a export policy with ID "xxx" and remove the rules`, + "$ stackit beta sfs export-policy update XXX --remove-rules", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + exportPolicyLabel, err := sfsUtils.GetExportPolicyName(ctx, apiClient, model.ProjectId, model.Region, model.ExportPolicyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get export policy name: %v", err) + exportPolicyLabel = model.ExportPolicyId + } else if exportPolicyLabel == "" { + exportPolicyLabel = model.ExportPolicyId + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to update export policy %q for project %q?", exportPolicyLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update export policy: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, exportPolicyLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), rulesFlag, "Rules of the export policy") + cmd.Flags().Bool(removeRulesFlag, false, "Remove the export policy rules") + + rulesFlags := []string{rulesFlag, removeRulesFlag} + cmd.MarkFlagsMutuallyExclusive(rulesFlags...) + cmd.MarkFlagsOneRequired(rulesFlags...) // Because the update endpoint supports only rules at the moment, one of the flags must be required +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiUpdateShareExportPolicyRequest { + req := apiClient.UpdateShareExportPolicy(ctx, model.ProjectId, model.Region, model.ExportPolicyId) + + payload := sfs.UpdateShareExportPolicyPayload{ + Rules: model.Rules, + } + return req.UpdateShareExportPolicyPayload(payload) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + exportPolicyId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + var rules *[]sfs.UpdateShareExportPolicyBodyRule + noRulesErr := fmt.Errorf("no rules specified") + if rulesString := flags.FlagToStringPointer(p, cmd, rulesFlag); rulesString != nil { + var r []sfs.UpdateShareExportPolicyBodyRule + err := json.Unmarshal([]byte(*rulesString), &r) + if err != nil { + return nil, fmt.Errorf("could not parse rules: %w", err) + } + if r == nil { + return nil, noRulesErr + } + rules = &r + } + + if removeRules := flags.FlagToBoolPointer(p, cmd, removeRulesFlag); removeRules != nil { + // Create an empty slice for the patch request + rules = &[]sfs.UpdateShareExportPolicyBodyRule{} + } + + // Because the update endpoint supports only rules at the moment, this should not be empty + if rules == nil { + return nil, noRulesErr + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ExportPolicyId: exportPolicyId, + Rules: rules, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, projectLabel, exportPolicyLabel string, resp *sfs.UpdateShareExportPolicyResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil { + p.Outputln("Empty export policy response") + return nil + } + p.Outputf("Updated export policy %q for project %q\n", exportPolicyLabel, projectLabel) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/export-policy/update/update_test.go b/internal/cmd/beta/sfs/export-policy/update/update_test.go new file mode 100644 index 000000000..b3c37080c --- /dev/null +++ b/internal/cmd/beta/sfs/export-policy/update/update_test.go @@ -0,0 +1,252 @@ +package update + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() + +const ( + testRegion = "eu01" + testRulesString = `[ + { + "ipAcl": ["172.16.0.0/24"], + "readOnly": true, + "order": 1 + } +]` +) + +var testRules = &[]sfs.UpdateShareExportPolicyBodyRule{ + { + IpAcl: utils.Ptr([]string{"172.16.0.0/24"}), + ReadOnly: utils.Ptr(true), + Order: utils.Ptr(int64(1)), + }, +} +var testExportPolicyId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + rulesFlag: testRulesString, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testExportPolicyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ExportPolicyId: testExportPolicyId, + Rules: testRules, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiUpdateShareExportPolicyRequest)) sfs.ApiUpdateShareExportPolicyRequest { + request := testClient.UpdateShareExportPolicy(testCtx, testProjectId, testRegion, testExportPolicyId) + request = request.UpdateShareExportPolicyPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *sfs.UpdateShareExportPolicyPayload)) sfs.UpdateShareExportPolicyPayload { + payload := sfs.UpdateShareExportPolicyPayload{ + Rules: testRules, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +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 rules specified", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, rulesFlag) + }), + isValid: false, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Rules = nil + }), + }, + { + description: "conflict rules and remove rules", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[rulesFlag] = testRulesString + flagValues[removeRulesFlag] = "true" + }), + isValid: false, + }, + { + description: "--remove-rules flag set", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[removeRulesFlag] = "true" + delete(flagValues, rulesFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Rules = &[]sfs.UpdateShareExportPolicyBodyRule{} + }), + }, + { + description: "required read rules from file", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[rulesFlag] = "@../test-files/rules-example.json" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Rules = &[]sfs.UpdateShareExportPolicyBodyRule{ + { + Description: sfs.NewNullableString( + utils.Ptr("first rule"), + ), + IpAcl: utils.Ptr([]string{"192.168.2.0/24"}), + Order: utils.Ptr(int64(1)), + SetUuid: utils.Ptr(true), + SuperUser: utils.Ptr(false), + }, + { + IpAcl: utils.Ptr([]string{"192.168.2.0/24", "127.0.0.1/32"}), + Order: utils.Ptr(int64(2)), + ReadOnly: utils.Ptr(true), + }, + } + }), + }, + } + opts := []testutils.TestingOption{ + testutils.WithCmpOptions(cmp.AllowUnexported(sfs.NullableString{})), + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithOptions(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, nil, tt.isValid, opts) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiUpdateShareExportPolicyRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + exportPolicyLabel string + resp *sfs.UpdateShareExportPolicyResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty resp", + args: args{ + resp: &sfs.UpdateShareExportPolicyResponse{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.exportPolicyLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/performance-class/list/list.go b/internal/cmd/beta/sfs/performance-class/list/list.go new file mode 100644 index 000000000..c16ed52e9 --- /dev/null +++ b/internal/cmd/beta/sfs/performance-class/list/list.go @@ -0,0 +1,111 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all performances classes available", + Long: "Lists all performances classes available.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all performances classes`, + "$ stackit beta sfs performance-class list", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, apiClient).Execute() + if err != nil { + return fmt.Errorf("list performance-class: %w", err) + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + performanceClasses := utils.GetSliceFromPointer(resp.PerformanceClasses) + + return outputResult(params.Printer, model.OutputFormat, projectLabel, performanceClasses) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, apiClient *sfs.APIClient) sfs.ApiListPerformanceClassesRequest { + return apiClient.ListPerformanceClasses(ctx) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, performanceClasses []sfs.PerformanceClass) error { + return p.OutputResult(outputFormat, performanceClasses, func() error { + if len(performanceClasses) == 0 { + p.Outputf("No performance classes found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("NAME", "IOPS", "THROUGHPUT") + for _, performanceClass := range performanceClasses { + table.AddRow( + utils.PtrString(performanceClass.Name), + utils.PtrString(performanceClass.Iops), + utils.PtrString(performanceClass.Throughput), + ) + } + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/performance-class/list/list_test.go b/internal/cmd/beta/sfs/performance-class/list/list_test.go new file mode 100644 index 000000000..696b47e53 --- /dev/null +++ b/internal/cmd/beta/sfs/performance-class/list/list_test.go @@ -0,0 +1,171 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiListPerformanceClassesRequest)) sfs.ApiListPerformanceClassesRequest { + request := testClient.ListPerformanceClasses(testCtx) + 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", + 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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + expectedRequest sfs.ApiListPerformanceClassesRequest + }{ + { + description: "base", + expectedRequest: fixtureRequest(), + }, + } + + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + performanceClasses []sfs.PerformanceClass + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty performance class", + args: args{ + performanceClasses: []sfs.PerformanceClass{ + {}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.performanceClasses); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/performance-class/performance_class.go b/internal/cmd/beta/sfs/performance-class/performance_class.go new file mode 100644 index 000000000..f033ee5d3 --- /dev/null +++ b/internal/cmd/beta/sfs/performance-class/performance_class.go @@ -0,0 +1,26 @@ +package performanceclass + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/performance-class/list" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "performance-class", + Short: "Provides functionality for SFS performance classes", + Long: "Provides functionality for SFS performance classes.", + Args: cobra.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/beta/sfs/resource-pool/create/create.go b/internal/cmd/beta/sfs/resource-pool/create/create.go new file mode 100644 index 000000000..38df9acf1 --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/create/create.go @@ -0,0 +1,187 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + performanceClassFlag = "performance-class" + sizeFlag = "size" + ipAclFlag = "ip-acl" + availabilityZoneFlag = "availability-zone" + nameFlag = "name" + snapshotsVisibleFlag = "snapshots-visible" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SizeInGB int64 + PerformanceClass string + IpAcl []string + Name string + AvailabilityZone string + SnapshotsVisible bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a SFS resource pool", + Long: `Creates a SFS resource pool. + +The available performance class values can be obtained by running: + $ stackit beta sfs performance-class list`, + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a SFS resource pool`, + "$ stackit beta sfs resource-pool create --availability-zone eu01-m --ip-acl 10.88.135.144/28 --performance-class Standard --size 500 --name resource-pool-01"), + examples.NewExample( + `Create a SFS resource pool, allow only a single IP which can mount the resource pool`, + "$ stackit beta sfs resource-pool create --availability-zone eu01-m --ip-acl 250.81.87.224/32 --performance-class Standard --size 500 --name resource-pool-01"), + examples.NewExample( + `Create a SFS resource pool, allow multiple IP ACL which can mount the resource pool`, + "$ stackit beta sfs resource-pool create --availability-zone eu01-m --ip-acl \"10.88.135.144/28,250.81.87.224/32\" --performance-class Standard --size 500 --name resource-pool-01"), + examples.NewExample( + `Create a SFS resource pool with visible snapshots`, + "$ stackit beta sfs resource-pool create --availability-zone eu01-m --ip-acl 10.88.135.144/28 --performance-class Standard --size 500 --name resource-pool-01 --snapshots-visible"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a resource-pool for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("create SFS resource pool: %w", err) + } + var resourcePoolId string + if resp != nil && resp.HasResourcePool() && resp.ResourcePool.HasId() { + resourcePoolId = *resp.ResourcePool.Id + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Create resource pool", func() error { + _, err = wait.CreateResourcePoolWaitHandler(ctx, apiClient, model.ProjectId, model.Region, resourcePoolId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for resource pool creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(sizeFlag, 0, "Size of the pool in Gigabytes") + cmd.Flags().String(performanceClassFlag, "", "Performance class") + cmd.Flags().Var(flags.CIDRSliceFlag(), ipAclFlag, "List of network addresses in the form
, e.g. 192.168.10.0/24 that can mount the resource pool readonly") + cmd.Flags().String(availabilityZoneFlag, "", "Availability zone") + cmd.Flags().String(nameFlag, "", "Name") + cmd.Flags().Bool(snapshotsVisibleFlag, false, "Set snapshots visible and accessible to users") + + for _, flag := range []string{sizeFlag, performanceClassFlag, ipAclFlag, availabilityZoneFlag, nameFlag} { + err := flags.MarkFlagsRequired(cmd, flag) + cobra.CheckErr(err) + } +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiCreateResourcePoolRequest { + req := apiClient.CreateResourcePool(ctx, model.ProjectId, model.Region) + req = req.CreateResourcePoolPayload(sfs.CreateResourcePoolPayload{ + AvailabilityZone: &model.AvailabilityZone, + IpAcl: &model.IpAcl, + Name: &model.Name, + PerformanceClass: &model.PerformanceClass, + SizeGigabytes: &model.SizeInGB, + SnapshotsAreVisible: &model.SnapshotsVisible, + }) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + performanceClass := flags.FlagToStringValue(p, cmd, performanceClassFlag) + size := flags.FlagWithDefaultToInt64Value(p, cmd, sizeFlag) + availabilityZone := flags.FlagToStringValue(p, cmd, availabilityZoneFlag) + ipAcls := flags.FlagToStringSlicePointer(p, cmd, ipAclFlag) + name := flags.FlagToStringValue(p, cmd, nameFlag) + snapshotsVisible := flags.FlagToBoolValue(p, cmd, snapshotsVisibleFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + SizeInGB: size, + IpAcl: *ipAcls, + PerformanceClass: performanceClass, + AvailabilityZone: availabilityZone, + Name: name, + SnapshotsVisible: snapshotsVisible, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, resp *sfs.CreateResourcePoolResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil || resp.ResourcePool == nil { + p.Outputln("Resource pool response is empty") + return nil + } + operationState := "Created" + if async { + operationState = "Triggered creation of" + } + p.Outputf("%s resource pool for project %q. Resource pool ID: %s\n", operationState, projectLabel, utils.PtrString(resp.ResourcePool.Id)) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/resource-pool/create/create_test.go b/internal/cmd/beta/sfs/resource-pool/create/create_test.go new file mode 100644 index 000000000..c9ac9ee8a --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/create/create_test.go @@ -0,0 +1,297 @@ +package create + +import ( + "context" + "strconv" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var ( + testProjectId = uuid.NewString() + testRegion = "eu02" + testResourcePoolPerformanceClass = "Standard" + testResourcePoolSizeInGB int64 = 50 + testResourcePoolAvailabilityZone = "eu02-m" + testResourcePoolName = "sfs-resource-pool-01" + testResourcePoolIpAcl = []string{"10.88.135.144/28", "250.81.87.224/32"} + testSnapshotsVisible = true +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + performanceClassFlag: testResourcePoolPerformanceClass, + sizeFlag: strconv.FormatInt(testResourcePoolSizeInGB, 10), + ipAclFlag: strings.Join(testResourcePoolIpAcl, ","), + availabilityZoneFlag: testResourcePoolAvailabilityZone, + nameFlag: testResourcePoolName, + snapshotsVisibleFlag: strconv.FormatBool(testSnapshotsVisible), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + PerformanceClass: testResourcePoolPerformanceClass, + AvailabilityZone: testResourcePoolAvailabilityZone, + Name: testResourcePoolName, + SizeInGB: testResourcePoolSizeInGB, + IpAcl: testResourcePoolIpAcl, + SnapshotsVisible: testSnapshotsVisible, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiCreateResourcePoolRequest)) sfs.ApiCreateResourcePoolRequest { + request := testClient.CreateResourcePool(testCtx, testProjectId, testRegion) + request = request.CreateResourcePoolPayload(sfs.CreateResourcePoolPayload{ + Name: &testResourcePoolName, + PerformanceClass: &testResourcePoolPerformanceClass, + AvailabilityZone: &testResourcePoolAvailabilityZone, + IpAcl: &testResourcePoolIpAcl, + SizeGigabytes: &testResourcePoolSizeInGB, + SnapshotsAreVisible: &testSnapshotsVisible, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + ipAclValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "ip acl missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ipAclFlag) + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "performance class missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, performanceClassFlag) + }), + isValid: false, + }, + { + description: "size missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, sizeFlag) + }), + isValid: false, + }, + { + description: "availability zone missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, availabilityZoneFlag) + }), + isValid: false, + }, + { + description: "missing snapshot visible - fallback to false", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, snapshotsVisibleFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SnapshotsVisible = false + }), + isValid: true, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "repeated ip acl flags", + flagValues: fixtureFlagValues(), + ipAclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.IpAcl = append(model.IpAcl, "198.51.100.14/24", "198.51.100.14/32") + }), + }, + { + description: "repeated ip acl flags with list value", + flagValues: fixtureFlagValues(), + ipAclValues: []string{"198.51.100.14/24,198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.IpAcl = append(model.IpAcl, "198.51.100.14/24", "198.51.100.14/32") + }), + }, + { + description: "invalid ip acl 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipAclFlag] = "foo-bar" + }), + isValid: false, + }, + { + description: "invalid ip acl 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipAclFlag] = "192.168.178.256/32" + }), + isValid: false, + }, + { + description: "invalid ip acl 3", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipAclFlag] = "192.168.178.255/32," + }), + isValid: false, + }, + { + description: "invalid ip acl 4", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipAclFlag] = "192.168.178.255/32," + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + ipAclFlag: tt.ipAclValues, + }, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiCreateResourcePoolRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + projectLabel string + resp *sfs.CreateResourcePoolResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + resp: &sfs.CreateResourcePoolResponse{}, + }, + wantErr: false, + }, + { + name: "set response", + args: args{ + resp: &sfs.CreateResourcePoolResponse{ + ResourcePool: &sfs.CreateResourcePoolResponseResourcePool{}, + }, + }, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/resource-pool/delete/delete.go b/internal/cmd/beta/sfs/resource-pool/delete/delete.go new file mode 100644 index 000000000..f67d92976 --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/delete/delete.go @@ -0,0 +1,123 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + resourcePoolIdArg = "RESOURCE_POOL_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Deletes a SFS resource pool", + Long: "Deletes a SFS resource pool.", + Args: args.SingleArg(resourcePoolIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete the SFS resource pool with ID "xxx"`, + "$ stackit beta sfs resource-pool delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + resourcePoolName, err := sfsUtils.GetResourcePoolName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolName = model.ResourcePoolId + } + + prompt := fmt.Sprintf("Are you sure you want to delete resource pool %q? (This cannot be undone)", resourcePoolName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("delete SFS resource pool: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Delete resource pool", func() error { + _, err = wait.DeleteResourcePoolWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for resource pool deletion: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, resourcePoolName, resp) + }, + } + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiDeleteResourcePoolRequest { + req := apiClient.DeleteResourcePool(ctx, model.ProjectId, model.Region, model.ResourcePoolId) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + resourcePoolId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ResourcePoolId: resourcePoolId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, async bool, resourcePoolName string, response map[string]interface{}) error { + return p.OutputResult(outputFormat, response, func() error { + operationState := "Deleted" + if async { + operationState = "Triggered deletion of" + } + p.Outputf("%s resource pool %q\n", operationState, resourcePoolName) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/resource-pool/delete/delete_test.go b/internal/cmd/beta/sfs/resource-pool/delete/delete_test.go new file mode 100644 index 000000000..439726ffd --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/delete/delete_test.go @@ -0,0 +1,211 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} +var testProjectId = uuid.NewString() +var testResourcePoolId = uuid.NewString() +var testRegion = "eu02" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testResourcePoolId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ResourcePoolId: testResourcePoolId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiDeleteResourcePoolRequest)) sfs.ApiDeleteResourcePoolRequest { + request := testClient.DeleteResourcePool(testCtx, testProjectId, testRegion, testResourcePoolId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "resource pool id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "resource pool id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiDeleteResourcePoolRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + resourcePoolName string + response map[string]interface{} + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "empty - output json", + args: args{ + outputFormat: print.JSONOutputFormat, + }, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.resourcePoolName, tt.args.response); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/resource-pool/describe/describe.go b/internal/cmd/beta/sfs/resource-pool/describe/describe.go new file mode 100644 index 000000000..c428b53e1 --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/describe/describe.go @@ -0,0 +1,153 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + resourcePoolIdArg = "RESOURCE_POOL_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Shows details of a SFS resource pool", + Long: "Shows details of a SFS resource pool.", + Args: args.SingleArg(resourcePoolIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe the SFS resource pool with ID "xxx"`, + "$ stackit beta sfs resource-pool describe xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("describe SFS resource pool: %w", err) + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, model.ResourcePoolId, projectLabel, resp.ResourcePool) + }, + } + return cmd +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiGetResourcePoolRequest { + req := apiClient.GetResourcePool(ctx, model.ProjectId, model.Region, model.ResourcePoolId) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + resourcePoolId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ResourcePoolId: resourcePoolId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, resourcePoolId, projectLabel string, resourcePool *sfs.GetResourcePoolResponseResourcePool) error { + return p.OutputResult(outputFormat, resourcePool, func() error { + if resourcePool == nil { + p.Outputf("Resource pool %q not found in project %q\n", resourcePoolId, projectLabel) + return nil + } + table := tables.NewTable() + + // convert the string slice to a comma separated list + var ipAclStr string + if resourcePool.IpAcl != nil { + ipAclStr = strings.Join(*resourcePool.IpAcl, ", ") + } + + table.AddRow("ID", utils.PtrString(resourcePool.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(resourcePool.Name)) + table.AddSeparator() + table.AddRow("AVAILABILITY ZONE", utils.PtrString(resourcePool.AvailabilityZone)) + table.AddSeparator() + table.AddRow("NUMBER OF SHARES", utils.PtrString(resourcePool.CountShares)) + table.AddSeparator() + table.AddRow("IP ACL", ipAclStr) + table.AddSeparator() + table.AddRow("MOUNT PATH", utils.PtrString(resourcePool.MountPath)) + table.AddSeparator() + if resourcePool.PerformanceClass != nil { + table.AddRow("PERFORMANCE CLASS", utils.PtrString(resourcePool.PerformanceClass.Name)) + table.AddSeparator() + } + table.AddRow("SNAPSHOTS ARE VISIBLE", utils.PtrString(resourcePool.SnapshotsAreVisible)) + table.AddSeparator() + table.AddRow("NEXT PERFORMANCE CLASS DOWNGRADE TIME", utils.PtrString(resourcePool.PerformanceClassDowngradableAt)) + table.AddSeparator() + table.AddRow("NEXT SIZE REDUCTION TIME", utils.PtrString(resourcePool.SizeReducibleAt)) + table.AddSeparator() + if resourcePool.HasSpace() { + table.AddRow("TOTAL SIZE (GB)", utils.PtrString(resourcePool.Space.SizeGigabytes)) + table.AddSeparator() + table.AddRow("AVAILABLE SIZE (GB)", utils.PtrString(resourcePool.Space.AvailableGigabytes)) + table.AddSeparator() + table.AddRow("USED SIZE (GB)", utils.PtrString(resourcePool.Space.UsedGigabytes)) + table.AddSeparator() + } + table.AddRow("STATE", utils.PtrString(resourcePool.State)) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/sfs/resource-pool/describe/describe_test.go b/internal/cmd/beta/sfs/resource-pool/describe/describe_test.go new file mode 100644 index 000000000..8f5b832ab --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/describe/describe_test.go @@ -0,0 +1,212 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testResourcePoolId = uuid.NewString() +var testRegion = "eu02" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testResourcePoolId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ResourcePoolId: testResourcePoolId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiGetResourcePoolRequest)) sfs.ApiGetResourcePoolRequest { + request := testClient.GetResourcePool(testCtx, testProjectId, testRegion, testResourcePoolId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "resource pool id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "resource pool id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiGetResourcePoolRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resourcePoolId string + projectLabel string + resp *sfs.GetResourcePoolResponseResourcePool + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + resp: &sfs.GetResourcePoolResponseResourcePool{}, + }, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.resourcePoolId, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/resource-pool/list/list.go b/internal/cmd/beta/sfs/resource-pool/list/list.go new file mode 100644 index 000000000..d70ebf5a8 --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/list/list.go @@ -0,0 +1,154 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all SFS resource pools", + Long: "Lists all SFS resource pools.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all SFS resource pools`, + "$ stackit beta sfs resource-pool list"), + examples.NewExample( + `List all SFS resource pools for another region than the default one`, + "$ stackit beta sfs resource-pool list --region eu01"), + examples.NewExample( + `List up to 10 SFS resource pools`, + "$ stackit beta sfs resource-pool list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("list SFS resource pools: %w", err) + } + + resourcePools := utils.GetSliceFromPointer(resp.ResourcePools) + + // Truncate output + if model.Limit != nil && len(resourcePools) > int(*model.Limit) { + resourcePools = resourcePools[:*model.Limit] + } + + // Get projectLabel + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resourcePools) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiListResourcePoolsRequest { + req := apiClient.ListResourcePools(ctx, model.ProjectId, model.Region) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &cliErr.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} +func outputResult(p *print.Printer, outputFormat, projectLabel string, resourcePools []sfs.ResourcePool) error { + return p.OutputResult(outputFormat, resourcePools, func() error { + if len(resourcePools) == 0 { + p.Outputf("No resource pools found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "AVAILABILITY ZONE", "STATE", "TOTAL SIZE (GB)", "USED SIZE (GB)") + for _, resourcePool := range resourcePools { + totalSizeGigabytes, usedSizeGigabytes := "", "" + if resourcePool.HasSpace() { + totalSizeGigabytes = utils.PtrString(resourcePool.Space.SizeGigabytes) + usedSizeGigabytes = utils.PtrString(resourcePool.Space.UsedGigabytes) + } + table.AddRow( + utils.PtrString(resourcePool.Id), + utils.PtrString(resourcePool.Name), + utils.PtrString(resourcePool.AvailabilityZone), + utils.PtrString(resourcePool.State), + totalSizeGigabytes, + usedSizeGigabytes, + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/beta/sfs/resource-pool/list/list_test.go b/internal/cmd/beta/sfs/resource-pool/list/list_test.go new file mode 100644 index 000000000..082022e68 --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/list/list_test.go @@ -0,0 +1,200 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} +var testProjectId = uuid.NewString() +var testRegion = "eu02" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiListResourcePoolsRequest)) sfs.ApiListResourcePoolsRequest { + request := testClient.ListResourcePools(testCtx, testProjectId, testRegion) + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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, + }, + { + description: "limit invalid 3", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-5" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiListResourcePoolsRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resourcePools []sfs.ResourcePool + projectLabel string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty resource pools slice", + args: args{ + resourcePools: []sfs.ResourcePool{}, + }, + wantErr: false, + }, + { + name: "set empty resource pool in resource pools slice", + args: args{ + resourcePools: []sfs.ResourcePool{{}}, + }, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.resourcePools); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/resource-pool/resource_pool.go b/internal/cmd/beta/sfs/resource-pool/resource_pool.go new file mode 100644 index 000000000..3198741b4 --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/resource_pool.go @@ -0,0 +1,34 @@ +package resourcepool + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/resource-pool/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/resource-pool/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/resource-pool/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/resource-pool/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/resource-pool/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "resource-pool", + Short: "Provides functionality for SFS resource pools", + Long: "Provides functionality for SFS resource pools.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) +} diff --git a/internal/cmd/beta/sfs/resource-pool/update/update.go b/internal/cmd/beta/sfs/resource-pool/update/update.go new file mode 100644 index 000000000..f70b6d7da --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/update/update.go @@ -0,0 +1,182 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + resourcePoolIdArg = "RESOURCE_POOL_ID" + performanceClassFlag = "performance-class" + sizeFlag = "size" + ipAclFlag = "ip-acl" + snapshotsVisibleFlag = "snapshots-visible" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SizeGigabytes *int64 + PerformanceClass *string + IpAcl *[]string + ResourcePoolId string + SnapshotsVisible *bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates a SFS resource pool", + Long: `Updates a SFS resource pool. + +The available performance class values can be obtained by running: + $ stackit beta sfs performance-class list`, + Args: args.SingleArg(resourcePoolIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the SFS resource pool with ID "xxx"`, + "$ stackit beta sfs resource-pool update xxx --ip-acl 10.88.135.144/28 --performance-class Standard --size 5"), + examples.NewExample( + `Update the SFS resource pool with ID "xxx", allow only a single IP which can mount the resource pool`, + "$ stackit beta sfs resource-pool update xxx --ip-acl 250.81.87.224/32 --performance-class Standard --size 5"), + examples.NewExample( + `Update the SFS resource pool with ID "xxx", allow multiple IP ACL which can mount the resource pool`, + "$ stackit beta sfs resource-pool update xxx --ip-acl \"10.88.135.144/28,250.81.87.224/32\" --performance-class Standard --size 5"), + examples.NewExample( + `Update the SFS resource pool with ID "xxx", set snapshots visible to false`, + "$ stackit beta sfs resource-pool update xxx --snapshots-visible=false"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + resourcePoolName, err := sfsUtils.GetResourcePoolName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolName = model.ResourcePoolId + } + + prompt := fmt.Sprintf("Are you sure you want to update resource-pool %q for project %q?", resourcePoolName, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + resp, err := buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("update SFS resource pool: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Update resource pool", func() error { + _, err = wait.UpdateResourcePoolWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for resource pool update: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(sizeFlag, 0, "Size of the pool in Gigabytes") + cmd.Flags().String(performanceClassFlag, "", "Performance class") + cmd.Flags().Var(flags.CIDRSliceFlag(), ipAclFlag, "List of network addresses in the form
, e.g. 192.168.10.0/24 that can mount the resource pool readonly") + cmd.Flags().Bool(snapshotsVisibleFlag, false, "Set snapshots visible and accessible to users") +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiUpdateResourcePoolRequest { + req := apiClient.UpdateResourcePool(ctx, model.ProjectId, model.Region, model.ResourcePoolId) + req = req.UpdateResourcePoolPayload(sfs.UpdateResourcePoolPayload{ + IpAcl: model.IpAcl, + PerformanceClass: model.PerformanceClass, + SizeGigabytes: model.SizeGigabytes, + SnapshotsAreVisible: model.SnapshotsVisible, + }) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + resourcePoolId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + performanceClass := flags.FlagToStringPointer(p, cmd, performanceClassFlag) + size := flags.FlagToInt64Pointer(p, cmd, sizeFlag) + ipAcls := flags.FlagToStringSlicePointer(p, cmd, ipAclFlag) + snapshotsVisible := flags.FlagToBoolPointer(p, cmd, snapshotsVisibleFlag) + + if performanceClass == nil && size == nil && ipAcls == nil && snapshotsVisible == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SizeGigabytes: size, + IpAcl: ipAcls, + PerformanceClass: performanceClass, + ResourcePoolId: resourcePoolId, + SnapshotsVisible: snapshotsVisible, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, async bool, resp *sfs.UpdateResourcePoolResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil || resp.ResourcePool == nil { + p.Outputln("Resource pool response is empty") + return nil + } + operationState := "Updated" + if async { + operationState = "Triggered update of" + } + p.Outputf("%s resource pool %s\n", operationState, utils.PtrString(resp.ResourcePool.Name)) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/resource-pool/update/update_test.go b/internal/cmd/beta/sfs/resource-pool/update/update_test.go new file mode 100644 index 000000000..0ed674765 --- /dev/null +++ b/internal/cmd/beta/sfs/resource-pool/update/update_test.go @@ -0,0 +1,363 @@ +package update + +import ( + "context" + "slices" + "strconv" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu02" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &sfs.APIClient{} + + testProjectId = uuid.NewString() + testResourcePoolId = uuid.NewString() + testResourcePoolIpAcl = []string{"10.88.135.144/28", "250.81.87.224/32"} + testResourcePoolPerformanceClass = "Standard" + testResourcePoolSizeInGB int64 = 50 + testSnapshotsVisible = true +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testResourcePoolId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + performanceClassFlag: testResourcePoolPerformanceClass, + sizeFlag: strconv.FormatInt(testResourcePoolSizeInGB, 10), + ipAclFlag: strings.Join(testResourcePoolIpAcl, ","), + snapshotsVisibleFlag: strconv.FormatBool(testSnapshotsVisible), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + ipAclClone := slices.Clone(testResourcePoolIpAcl) + + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ResourcePoolId: testResourcePoolId, + SizeGigabytes: &testResourcePoolSizeInGB, + PerformanceClass: &testResourcePoolPerformanceClass, + IpAcl: &ipAclClone, + SnapshotsVisible: &testSnapshotsVisible, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiUpdateResourcePoolRequest)) sfs.ApiUpdateResourcePoolRequest { + request := testClient.UpdateResourcePool(testCtx, testProjectId, testRegion, testResourcePoolId) + request = request.UpdateResourcePoolPayload(sfs.UpdateResourcePoolPayload{ + IpAcl: &testResourcePoolIpAcl, + PerformanceClass: &testResourcePoolPerformanceClass, + SizeGigabytes: &testResourcePoolSizeInGB, + SnapshotsAreVisible: &testSnapshotsVisible, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + ipAclValues []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: "no values to update", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, sizeFlag) + delete(flagValues, ipAclFlag) + delete(flagValues, performanceClassFlag) + delete(flagValues, snapshotsVisibleFlag) + }), + isValid: false, + }, + { + description: "update only size", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ipAclFlag) + delete(flagValues, performanceClassFlag) + delete(flagValues, snapshotsVisibleFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.IpAcl = nil + model.PerformanceClass = nil + model.SnapshotsVisible = nil + }), + isValid: true, + }, + { + description: "update only snapshots visibility", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ipAclFlag) + delete(flagValues, performanceClassFlag) + delete(flagValues, sizeFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.IpAcl = nil + model.PerformanceClass = nil + model.SizeGigabytes = nil + }), + isValid: true, + }, + { + description: "update only performance class", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ipAclFlag) + delete(flagValues, snapshotsVisibleFlag) + delete(flagValues, sizeFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.IpAcl = nil + model.SnapshotsVisible = nil + model.SizeGigabytes = nil + }), + isValid: true, + }, + { + description: "update only ipAcl", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, performanceClassFlag) + delete(flagValues, snapshotsVisibleFlag) + delete(flagValues, sizeFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PerformanceClass = nil + model.SnapshotsVisible = nil + model.SizeGigabytes = nil + }), + isValid: true, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + flagValues[sizeFlag] = "50" + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + flagValues[sizeFlag] = "50" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + flagValues[sizeFlag] = "50" + }), + isValid: false, + }, + { + description: "resource pool id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "resource pool id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "repeated acl flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + ipAclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + if model.IpAcl == nil { + model.IpAcl = &[]string{} + } + *model.IpAcl = append(*model.IpAcl, "198.51.100.14/24", "198.51.100.14/32") + }), + }, + { + description: "repeated ip acl flag with list value", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + ipAclValues: []string{"198.51.100.14/24,198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + if model.IpAcl == nil { + model.IpAcl = &[]string{} + } + *model.IpAcl = append(*model.IpAcl, "198.51.100.14/24", "198.51.100.14/32") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + ipAclFlag: tt.ipAclValues, + }, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiUpdateResourcePoolRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + resp *sfs.UpdateResourcePoolResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "empty response", + args: args{ + resp: &sfs.UpdateResourcePoolResponse{}, + }, + wantErr: false, + }, + { + name: "valid response with empty resource pool", + args: args{ + resp: &sfs.UpdateResourcePoolResponse{ + ResourcePool: &sfs.UpdateResourcePoolResponseResourcePool{}, + }, + }, + wantErr: false, + }, + { + name: "valid response with name", + args: args{ + resp: &sfs.UpdateResourcePoolResponse{ + ResourcePool: &sfs.UpdateResourcePoolResponseResourcePool{ + Name: utils.Ptr("example name"), + }, + }, + }, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/sfs.go b/internal/cmd/beta/sfs/sfs.go new file mode 100644 index 000000000..2477e4843 --- /dev/null +++ b/internal/cmd/beta/sfs/sfs.go @@ -0,0 +1,34 @@ +package sfs + +import ( + exportpolicy "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/export-policy" + performanceclass "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/performance-class" + resourcepool "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/resource-pool" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/share" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "sfs", + Short: "Provides functionality for SFS (stackit file storage)", + Long: "Provides functionality for SFS (stackit file storage).", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(resourcepool.NewCmd(params)) + cmd.AddCommand(share.NewCmd(params)) + cmd.AddCommand(exportpolicy.NewCmd(params)) + cmd.AddCommand(snapshot.NewCmd(params)) + cmd.AddCommand(performanceclass.NewCmd(params)) +} diff --git a/internal/cmd/beta/sfs/share/create/create.go b/internal/cmd/beta/sfs/share/create/create.go new file mode 100644 index 000000000..3a565f197 --- /dev/null +++ b/internal/cmd/beta/sfs/share/create/create.go @@ -0,0 +1,180 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + nameFlag = "name" + resourcePoolIdFlag = "resource-pool-id" + exportPolicyNameFlag = "export-policy-name" + hardLimitFlag = "hard-limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string + ExportPolicyName *string + ResourcePoolId string + HardLimit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a share", + Long: "Creates a share.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a share in a resource pool with ID "xxx", name "yyy" and no space hard limit`, + "$ stackit beta sfs share create --resource-pool-id xxx --name yyy --hard-limit 0", + ), + examples.NewExample( + `Create a share in a resource pool with ID "xxx", name "yyy" and export policy with name "zzz"`, + "$ stackit beta sfs share create --resource-pool-id xxx --name yyy --export-policy-name zzz --hard-limit 0", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + resourcePoolLabel, err := sfsUtils.GetResourcePoolName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolLabel = model.ResourcePoolId + } else if resourcePoolLabel == "" { + resourcePoolLabel = model.ResourcePoolId + } + + prompt := fmt.Sprintf("Are you sure you want to create a SFS share for resource pool %q?", resourcePoolLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create SFS share: %w", err) + } + var shareId string + if resp != nil && resp.HasShare() && resp.Share.HasId() { + shareId = *resp.Share.Id + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating share", func() error { + _, err = wait.CreateShareWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId, shareId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("waiting for share creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, resourcePoolLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Share name") + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool the share is assigned to") + cmd.Flags().String(exportPolicyNameFlag, "", "The export policy the share is assigned to") + cmd.Flags().Int64(hardLimitFlag, 0, "The space hard limit for the share") + + err := flags.MarkFlagsRequired(cmd, nameFlag, resourcePoolIdFlag, hardLimitFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + hardLimit := flags.FlagToInt64Pointer(p, cmd, hardLimitFlag) + if hardLimit != nil { + if *hardLimit < 0 { + return nil, &errors.FlagValidationError{ + Flag: hardLimitFlag, + Details: "must be a positive integer", + } + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + ExportPolicyName: flags.FlagToStringPointer(p, cmd, exportPolicyNameFlag), + HardLimit: hardLimit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiCreateShareRequest { + req := apiClient.CreateShare(ctx, model.ProjectId, model.Region, model.ResourcePoolId) + req = req.CreateSharePayload( + sfs.CreateSharePayload{ + Name: utils.Ptr(model.Name), + ExportPolicyName: sfs.NewNullableString(model.ExportPolicyName), + SpaceHardLimitGigabytes: model.HardLimit, + }, + ) + return req +} + +func outputResult(p *print.Printer, outputFormat string, async bool, resourcePoolLabel string, item *sfs.CreateShareResponse) error { + return p.OutputResult(outputFormat, item, func() error { + if item == nil || item.Share == nil { + p.Outputln("SFS share response is empty") + return nil + } + operation := "Created" + if async { + operation = "Triggered creation of" + } + p.Outputf( + "%s SFS Share %q in resource pool %q.\nShare ID: %s\n", + operation, + utils.PtrString(item.Share.Name), + resourcePoolLabel, + utils.PtrString(item.Share.Id), + ) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/share/create/create_test.go b/internal/cmd/beta/sfs/share/create/create_test.go new file mode 100644 index 000000000..bbbf1ba42 --- /dev/null +++ b/internal/cmd/beta/sfs/share/create/create_test.go @@ -0,0 +1,208 @@ +package create + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testName = "test-name" +var testResourcePoolId = uuid.NewString() +var testExportPolicyName = "test-export-policy" +var testHardLimit int64 = 10 + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + nameFlag: testName, + resourcePoolIdFlag: testResourcePoolId, + exportPolicyNameFlag: testExportPolicyName, + hardLimitFlag: strconv.Itoa(int(testHardLimit)), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Name: testName, + ResourcePoolId: testResourcePoolId, + ExportPolicyName: utils.Ptr(testExportPolicyName), + HardLimit: utils.Ptr(testHardLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiCreateShareRequest)) sfs.ApiCreateShareRequest { + request := testClient.CreateShare(testCtx, testProjectId, testRegion, testResourcePoolId) + request = request.CreateSharePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(request *sfs.CreateSharePayload)) sfs.CreateSharePayload { + payload := sfs.CreateSharePayload{ + Name: utils.Ptr(testName), + ExportPolicyName: sfs.NewNullableString(utils.Ptr(testExportPolicyName)), + SpaceHardLimitGigabytes: utils.Ptr(testHardLimit), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "required only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, exportPolicyNameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ExportPolicyName = nil + }), + }, + { + description: "missing required name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "missing required resourcePoolId", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, resourcePoolIdFlag) + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiCreateShareRequest + }{ + { + 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), + cmp.AllowUnexported(sfs.NullableString{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + resourcePoolLabel string + item *sfs.CreateShareResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + item: &sfs.CreateShareResponse{}, + }, + wantErr: false, + }, + { + name: "set empty response share", + args: args{ + item: &sfs.CreateShareResponse{ + Share: &sfs.CreateShareResponseShare{}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.resourcePoolLabel, tt.args.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/share/delete/delete.go b/internal/cmd/beta/sfs/share/delete/delete.go new file mode 100644 index 000000000..c9086fe77 --- /dev/null +++ b/internal/cmd/beta/sfs/share/delete/delete.go @@ -0,0 +1,133 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + shareIdArg = "SHARE_ID" + + resourcePoolIdFlag = "resource-pool-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string + ShareId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", shareIdArg), + Short: "Deletes a share", + Long: "Deletes a share.", + Args: args.SingleArg(shareIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a share with ID "xxx" from a resource pool with ID "yyy"`, + "$ stackit beta sfs share delete xxx --resource-pool-id yyy", + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + shareLabel, err := sfsUtils.GetShareName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get share name: %v", err) + shareLabel = model.ShareId + } else if shareLabel == "" { + shareLabel = model.ShareId + } + + prompt := fmt.Sprintf("Are you sure you want to delete SFS share %q? (This cannot be undone)", shareLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete SFS share: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting share", func() error { + _, err = wait.DeleteShareWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("waiting for share deletion: %w", err) + } + } + + operation := "Deleted" + if model.Async { + operation = "Triggered deletion of" + } + + params.Printer.Outputf("%s share %q\n", operation, shareLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool the share is assigned to") + + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + shareId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ShareId: shareId, + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiDeleteShareRequest { + return apiClient.DeleteShare(ctx, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId) +} diff --git a/internal/cmd/beta/sfs/share/delete/delete_test.go b/internal/cmd/beta/sfs/share/delete/delete_test.go new file mode 100644 index 000000000..a6526653b --- /dev/null +++ b/internal/cmd/beta/sfs/share/delete/delete_test.go @@ -0,0 +1,187 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testResourcePoolId = uuid.NewString() +var testShareId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + resourcePoolIdFlag: testResourcePoolId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testShareId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ResourcePoolId: testResourcePoolId, + ShareId: testShareId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiDeleteShareRequest)) sfs.ApiDeleteShareRequest { + request := testClient.DeleteShare(testCtx, testProjectId, testRegion, testResourcePoolId, testShareId) + 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: "share id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "share id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing required resourcePoolId", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, resourcePoolIdFlag) + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiDeleteShareRequest + }{ + { + 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/beta/sfs/share/describe/describe.go b/internal/cmd/beta/sfs/share/describe/describe.go new file mode 100644 index 000000000..9fb2b1f06 --- /dev/null +++ b/internal/cmd/beta/sfs/share/describe/describe.go @@ -0,0 +1,192 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + shareIdArg = "SHARE_ID" + + resourcePoolIdFlag = "resource-pool-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string + ShareId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", shareIdArg), + Short: "Shows details of a shares", + Long: "Shows details of a shares.", + Args: args.SingleArg(shareIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a shares with ID "xxx" from resource pool with ID "yyy"`, + "$ stackit beta sfs export-policy describe xxx --resource-pool-id yyy", + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe SFS share: %w", err) + } + + resourcePoolLabel, err := sfsUtils.GetResourcePoolName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolLabel = model.ResourcePoolId + } else if resourcePoolLabel == "" { + resourcePoolLabel = model.ResourcePoolId + } + + return outputResult(params.Printer, model.OutputFormat, resourcePoolLabel, model.ShareId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool the share is assigned to") + + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + shareId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + ShareId: shareId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiGetShareRequest { + return apiClient.GetShare(ctx, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId) +} + +func outputResult(p *print.Printer, outputFormat, resourcePoolLabel, shareId string, share *sfs.GetShareResponse) error { + return p.OutputResult(outputFormat, share, func() error { + if share == nil || share.Share == nil { + p.Outputf("Share %q not found in resource pool %q\n", shareId, resourcePoolLabel) + return nil + } + + var content []tables.Table + + table := tables.NewTable() + table.SetTitle("Share") + item := *share.Share + + table.AddRow("ID", utils.PtrString(item.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(item.Name)) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(item.State)) + table.AddSeparator() + table.AddRow("MOUNT PATH", utils.PtrString(item.MountPath)) + table.AddSeparator() + table.AddRow("HARD LIMIT (GB)", utils.PtrString(item.SpaceHardLimitGigabytes)) + table.AddSeparator() + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(item.CreatedAt)) + + content = append(content, table) + + if item.HasExportPolicy() { + policyTable := tables.NewTable() + policyTable.SetTitle("Export Policy") + + policyTable.SetHeader( + "ID", + "NAME", + "SHARES USING EXPORT POLICY", + "CREATED AT", + ) + + policy := item.ExportPolicy.Get() + + policyTable.AddRow( + utils.PtrString(policy.Id), + utils.PtrString(policy.Name), + utils.PtrString(policy.SharesUsingExportPolicy), + utils.ConvertTimePToDateTimeString(policy.CreatedAt), + ) + + content = append(content, policyTable) + + if policy.Rules != nil && len(*policy.Rules) > 0 { + ruleTable := tables.NewTable() + ruleTable.SetTitle("Export Policy - Rules") + + ruleTable.SetHeader("ID", "ORDER", "DESCRIPTION", "IP ACL", "READ ONLY", "SET UUID", "SUPER USER", "CREATED AT") + + for _, rule := range *policy.Rules { + var description string + if rule.Description != nil { + description = utils.PtrString(rule.Description.Get()) + } + ruleTable.AddRow( + utils.PtrString(rule.Id), + utils.PtrString(rule.Order), + description, + utils.JoinStringPtr(rule.IpAcl, ", "), + utils.PtrString(rule.ReadOnly), + utils.PtrString(rule.SetUuid), + utils.PtrString(rule.SuperUser), + utils.ConvertTimePToDateTimeString(rule.CreatedAt), + ) + ruleTable.AddSeparator() + } + + content = append(content, ruleTable) + } + } + + if err := tables.DisplayTables(p, content); err != nil { + return fmt.Errorf("render tables: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/beta/sfs/share/describe/describe_test.go b/internal/cmd/beta/sfs/share/describe/describe_test.go new file mode 100644 index 000000000..4a5cc4d37 --- /dev/null +++ b/internal/cmd/beta/sfs/share/describe/describe_test.go @@ -0,0 +1,234 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testResourcePoolId = uuid.NewString() +var testShareId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + resourcePoolIdFlag: testResourcePoolId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testShareId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ResourcePoolId: testResourcePoolId, + ShareId: testShareId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiGetShareRequest)) sfs.ApiGetShareRequest { + request := testClient.GetShare(testCtx, testProjectId, testRegion, testResourcePoolId, testShareId) + 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: "share id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "share id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing required resourcePoolId", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, resourcePoolIdFlag) + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiGetShareRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + shareId string + resourcePoolLabel string + share *sfs.GetShareResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + share: &sfs.GetShareResponse{}, + }, + wantErr: false, + }, + { + name: "set empty share", + args: args{ + share: &sfs.GetShareResponse{ + Share: &sfs.GetShareResponseShare{}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.resourcePoolLabel, tt.args.shareId, tt.args.share); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/share/list/list.go b/internal/cmd/beta/sfs/share/list/list.go new file mode 100644 index 000000000..9f0aa1386 --- /dev/null +++ b/internal/cmd/beta/sfs/share/list/list.go @@ -0,0 +1,159 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + resourcePoolIdFlag = "resource-pool-id" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all shares of a resource pool", + Long: "Lists all shares of a resource pool.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all shares from resource pool with ID "xxx"`, + "$ stackit beta sfs export-policy list --resource-pool-id xxx", + ), + examples.NewExample( + `List up to 10 shares from resource pool with ID "xxx"`, + "$ stackit beta sfs export-policy list --resource-pool-id xxx --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list SFS share: %w", err) + } + + resourcePoolLabel, err := sfsUtils.GetResourcePoolName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolLabel = model.ResourcePoolId + } else if resourcePoolLabel == "" { + resourcePoolLabel = model.ResourcePoolId + } + + // Truncate output + items := utils.GetSliceFromPointer(resp.Shares) + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, resourcePoolLabel, items) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool the share is assigned to") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be grater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiListSharesRequest { + return apiClient.ListShares(ctx, model.ProjectId, model.Region, model.ResourcePoolId) +} + +func outputResult(p *print.Printer, outputFormat, resourcePoolLabel string, shares []sfs.Share) error { + return p.OutputResult(outputFormat, shares, func() error { + if len(shares) == 0 { + p.Info("No shares found for resource pool %q\n", resourcePoolLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATE", "EXPORT POLICY", "MOUNT PATH", "HARD LIMIT (GB)", "CREATED AT") + + for _, share := range shares { + var policy string + if share.ExportPolicy != nil { + if name, ok := share.ExportPolicy.Get().GetNameOk(); ok { + policy = name + } else if id, ok := share.ExportPolicy.Get().GetIdOk(); ok { + policy = id + } + } + table.AddRow( + utils.PtrString(share.Id), + utils.PtrString(share.Name), + utils.PtrString(share.State), + policy, + utils.PtrString(share.MountPath), + utils.PtrString(share.SpaceHardLimitGigabytes), + utils.ConvertTimePToDateTimeString(share.CreatedAt), + ) + } + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/share/list/list_test.go b/internal/cmd/beta/sfs/share/list/list_test.go new file mode 100644 index 000000000..8c5ba498a --- /dev/null +++ b/internal/cmd/beta/sfs/share/list/list_test.go @@ -0,0 +1,208 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testResourcePoolId = uuid.NewString() +var testLimit int64 = 10 + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + resourcePoolIdFlag: testResourcePoolId, + limitFlag: strconv.FormatInt(testLimit, 10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ResourcePoolId: testResourcePoolId, + Limit: utils.Ptr(testLimit), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiListSharesRequest)) sfs.ApiListSharesRequest { + request := testClient.ListShares(testCtx, testProjectId, testRegion, testResourcePoolId) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flag 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: "missing required resourcePoolId", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, resourcePoolIdFlag) + }), + isValid: false, + }, + { + description: "invalid limit 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "invalid limit 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-1" + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiListSharesRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resourcePoolLabel string + shares []sfs.Share + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty share in shares", + args: args{ + shares: []sfs.Share{{}}, + }, + wantErr: false, + }, + { + name: "set empty shares", + args: args{ + shares: []sfs.Share{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.resourcePoolLabel, tt.args.shares); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/share/share.go b/internal/cmd/beta/sfs/share/share.go new file mode 100644 index 000000000..1ea180fd2 --- /dev/null +++ b/internal/cmd/beta/sfs/share/share.go @@ -0,0 +1,34 @@ +package share + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/share/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/share/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/share/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/share/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/share/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "share", + Short: "Provides functionality for SFS shares", + Long: "Provides functionality for SFS shares.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/beta/sfs/share/update/update.go b/internal/cmd/beta/sfs/share/update/update.go new file mode 100644 index 000000000..a13257c81 --- /dev/null +++ b/internal/cmd/beta/sfs/share/update/update.go @@ -0,0 +1,180 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + shareIdArg = "SHARE_ID" + + resourcePoolIdFlag = "resource-pool-id" + exportPolicyNameFlag = "export-policy-name" + hardLimitFlag = "hard-limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ShareId string + ResourcePoolId string + ExportPolicyName *string + HardLimit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", shareIdArg), + Short: "Updates a share", + Long: "Updates a share.", + Args: args.SingleArg(shareIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update share with ID "xxx" with new export-policy-name "yyy" in resource-pool "zzz"`, + "$ stackit beta sfs share update xxx --export-policy-name yyy --resource-pool-id zzz", + ), + examples.NewExample( + `Update share with ID "xxx" with new space hard limit "50" in resource-pool "yyy"`, + "$ stackit beta sfs share update xxx --hard-limit 50 --resource-pool-id yyy", + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return fmt.Errorf("unable to parse input: %w", err) + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + shareLabel, err := sfsUtils.GetShareName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get share name: %v", err) + shareLabel = model.ShareId + } else if shareLabel == "" { + shareLabel = model.ShareId + } + + resourcePoolLabel, err := sfsUtils.GetResourcePoolName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolLabel = model.ResourcePoolId + } else if resourcePoolLabel == "" { + resourcePoolLabel = model.ResourcePoolId + } + + prompt := fmt.Sprintf("Are you sure you want to update SFS share %q for resource pool %q?", shareLabel, resourcePoolLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update SFS share: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Updating share", func() error { + _, err = wait.UpdateShareWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("waiting for share update: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, resourcePoolLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool the share is assigned to") + cmd.Flags().String(exportPolicyNameFlag, "", "The export policy the share is assigned to") + cmd.Flags().Int64(hardLimitFlag, 0, "The space hard limit for the share") + + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + shareId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + hardLimit := flags.FlagToInt64Pointer(p, cmd, hardLimitFlag) + if hardLimit != nil && *hardLimit < 0 { + return nil, &errors.FlagValidationError{ + Flag: hardLimitFlag, + Details: "must be a positive integer", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + ExportPolicyName: flags.FlagToStringPointer(p, cmd, exportPolicyNameFlag), + HardLimit: hardLimit, + ShareId: shareId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiUpdateShareRequest { + req := apiClient.UpdateShare(ctx, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId) + req = req.UpdateSharePayload(sfs.UpdateSharePayload{ + ExportPolicyName: sfs.NewNullableString(model.ExportPolicyName), + SpaceHardLimitGigabytes: model.HardLimit, + }) + return req +} + +func outputResult(p *print.Printer, outputFormat string, async bool, resourcePoolLabel string, item *sfs.UpdateShareResponse) error { + return p.OutputResult(outputFormat, item, func() error { + if item == nil || item.Share == nil { + p.Outputln("SFS share response is empty") + return nil + } + + operation := "Updated" + if async { + operation = "Triggered update of" + } + p.Outputf( + "%s SFS share %q in resource pool %q.\n", + operation, + utils.PtrString(item.Share.Name), + resourcePoolLabel, + ) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/share/update/update_test.go b/internal/cmd/beta/sfs/share/update/update_test.go new file mode 100644 index 000000000..5a108d9c4 --- /dev/null +++ b/internal/cmd/beta/sfs/share/update/update_test.go @@ -0,0 +1,267 @@ +package update + +import ( + "context" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testResourcePoolId = uuid.NewString() +var testShareId = uuid.NewString() +var testHardLimit int64 = 10 +var testExportPolicy = "test-export-policy" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + resourcePoolIdFlag: testResourcePoolId, + hardLimitFlag: strconv.FormatInt(testHardLimit, 10), + exportPolicyNameFlag: testExportPolicy, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testShareId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ResourcePoolId: testResourcePoolId, + ShareId: testShareId, + HardLimit: utils.Ptr(testHardLimit), + ExportPolicyName: utils.Ptr(testExportPolicy), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiUpdateShareRequest)) sfs.ApiUpdateShareRequest { + request := testClient.UpdateShare(testCtx, testProjectId, testRegion, testResourcePoolId, testShareId) + request = request.UpdateSharePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *sfs.UpdateSharePayload)) sfs.UpdateSharePayload { + payload := sfs.UpdateSharePayload{ + ExportPolicyName: sfs.NewNullableString(utils.Ptr(testExportPolicy)), + SpaceHardLimitGigabytes: utils.Ptr(testHardLimit), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +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: "only required flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, exportPolicyNameFlag) + delete(flagValues, hardLimitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ExportPolicyName = nil + model.HardLimit = nil + }), + }, + { + 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: "share id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "share id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing required resourcePoolId", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, resourcePoolIdFlag) + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiUpdateShareRequest + }{ + { + 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, sfs.NullableString{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + resourcePoolLabel string + item *sfs.UpdateShareResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + item: &sfs.UpdateShareResponse{}, + }, + wantErr: false, + }, + { + name: "set empty share", + args: args{ + item: &sfs.UpdateShareResponse{ + Share: &sfs.UpdateShareResponseShare{}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.resourcePoolLabel, tt.args.item); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/snapshot/create/create.go b/internal/cmd/beta/sfs/snapshot/create/create.go new file mode 100644 index 000000000..5344ec33a --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/create/create.go @@ -0,0 +1,141 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + resourcePoolIdFlag = "resource-pool-id" + nameFlag = "name" + commentFlag = "comment" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string + Name string + Comment *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new snapshot of a resource pool", + Long: "Creates a new snapshot of a resource pool.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new snapshot with name "snapshot-name" of a resource pool with ID "xxx"`, + "$ stackit beta sfs snapshot create --name snapshot-name --resource-pool-id xxx", + ), + examples.NewExample( + `Create a new snapshot with name "snapshot-name" and comment "snapshot-comment" of a resource pool with ID "xxx"`, + `$ stackit beta sfs snapshot create --name snapshot-name --resource-pool-id xxx --comment "snapshot-comment"`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + resourcePoolLabel, err := sfsUtils.GetResourcePoolName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolLabel = model.ResourcePoolId + } else if resourcePoolLabel == "" { + resourcePoolLabel = model.ResourcePoolId + } + + prompt := fmt.Sprintf("Are you sure you want to create a snapshot for resource pool %q?", resourcePoolLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create snapshot: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.Name, resourcePoolLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Snapshot name") + cmd.Flags().String(commentFlag, "", "A comment to add more information to the snapshot") + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool from which the snapshot should be created") + + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag, nameFlag) + cobra.CheckErr(err) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiCreateResourcePoolSnapshotRequest { + req := apiClient.CreateResourcePoolSnapshot(ctx, model.ProjectId, model.Region, model.ResourcePoolId) + req = req.CreateResourcePoolSnapshotPayload(sfs.CreateResourcePoolSnapshotPayload{ + Name: utils.Ptr(model.Name), + Comment: sfs.NewNullableString(model.Comment), + }) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringValue(p, cmd, nameFlag), + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + Comment: flags.FlagToStringPointer(p, cmd, commentFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, snapshotLabel, resourcePoolLabel string, resp *sfs.CreateResourcePoolSnapshotResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil || resp.ResourcePoolSnapshot == nil { + p.Outputln("SFS snapshot response is empty") + return nil + } + + p.Outputf( + "Created snapshot %q for resource pool %q.\n", + snapshotLabel, + resourcePoolLabel, + ) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/snapshot/create/create_test.go b/internal/cmd/beta/sfs/snapshot/create/create_test.go new file mode 100644 index 000000000..209e11c18 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/create/create_test.go @@ -0,0 +1,219 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testName = "test-name" +var testComment = "test-comment" +var testResourcePoolId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + nameFlag: testName, + resourcePoolIdFlag: testResourcePoolId, + commentFlag: testComment, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Name: testName, + ResourcePoolId: testResourcePoolId, + Comment: utils.Ptr(testComment), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiCreateResourcePoolSnapshotRequest)) sfs.ApiCreateResourcePoolSnapshotRequest { + request := testClient.CreateResourcePoolSnapshot(testCtx, testProjectId, testRegion, testResourcePoolId) + request = request.CreateResourcePoolSnapshotPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(request *sfs.CreateResourcePoolSnapshotPayload)) sfs.CreateResourcePoolSnapshotPayload { + payload := sfs.CreateResourcePoolSnapshotPayload{ + Name: utils.Ptr(testName), + Comment: sfs.NewNullableString( + utils.Ptr(testComment), + ), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "required only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, commentFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Comment = nil + }), + }, + { + description: "missing required name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "missing required resourcePoolId", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, resourcePoolIdFlag) + }), + isValid: false, + }, + { + description: "invalid resource pool id 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid resource pool id 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "invalid-resource-pool-id" + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiCreateResourcePoolSnapshotRequest + }{ + { + 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), + cmp.AllowUnexported(sfs.NullableString{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + snapshotLabel string + resourcePoolLabel string + resp *sfs.CreateResourcePoolSnapshotResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + resp: &sfs.CreateResourcePoolSnapshotResponse{}, + }, + wantErr: false, + }, + { + name: "set empty snapshot", + args: args{ + resp: &sfs.CreateResourcePoolSnapshotResponse{ + ResourcePoolSnapshot: &sfs.CreateResourcePoolSnapshotResponseResourcePoolSnapshot{}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.snapshotLabel, tt.args.resourcePoolLabel, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/snapshot/delete/delete.go b/internal/cmd/beta/sfs/snapshot/delete/delete.go new file mode 100644 index 000000000..fcc74be46 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/delete/delete.go @@ -0,0 +1,113 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + sfsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +const ( + snapshotNameArg = "SNAPSHOT_NAME" + + resourcePoolIdFlag = "resource-pool-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string + SnapshotName string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", snapshotNameArg), + Short: "Deletes a snapshot", + Long: "Deletes a snapshot.", + Args: args.SingleArg(snapshotNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete a snapshot with "SNAPSHOT_NAME" from resource pool with ID "yyy"`, + "$ stackit beta sfs snapshot delete SNAPSHOT_NAME --resource-pool-id yyy"), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + resourcePoolLabel, err := sfsUtils.GetResourcePoolName(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get resource pool name: %v", err) + resourcePoolLabel = model.ResourcePoolId + } else if resourcePoolLabel == "" { + resourcePoolLabel = model.ResourcePoolId + } + + prompt := fmt.Sprintf("Are you sure you want to delete snapshot %q for resource pool %q?", model.SnapshotName, resourcePoolLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete snapshot: %w", err) + } + + params.Printer.Outputf("Deleted snapshot %q from resource pool %q.\n", model.SnapshotName, resourcePoolLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool from which the snapshot should be created") + + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag) + cobra.CheckErr(err) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiDeleteResourcePoolSnapshotRequest { + return apiClient.DeleteResourcePoolSnapshot(ctx, model.ProjectId, model.Region, model.ResourcePoolId, model.SnapshotName) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotName: snapshotName, + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/beta/sfs/snapshot/delete/delete_test.go b/internal/cmd/beta/sfs/snapshot/delete/delete_test.go new file mode 100644 index 000000000..db010e22a --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/delete/delete_test.go @@ -0,0 +1,189 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testResourcePoolId = uuid.NewString() +var testSnapshotName = "testSnapshot" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotName, + } + 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, + regionFlag: testRegion, + + resourcePoolIdFlag: testResourcePoolId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ResourcePoolId: testResourcePoolId, + SnapshotName: testSnapshotName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiDeleteResourcePoolSnapshotRequest)) sfs.ApiDeleteResourcePoolSnapshotRequest { + request := testClient.DeleteResourcePoolSnapshot(testCtx, testProjectId, testRegion, testResourcePoolId, testSnapshotName) + 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: "share id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "resource pool invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "" + }), + isValid: false, + }, + { + description: "resource pool invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiDeleteResourcePoolSnapshotRequest + }{ + { + 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/beta/sfs/snapshot/describe/describe.go b/internal/cmd/beta/sfs/snapshot/describe/describe.go new file mode 100644 index 000000000..123e60172 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/describe/describe.go @@ -0,0 +1,130 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + snapshotNameArg = "SNAPSHOT_NAME" + + resourcePoolIdFlag = "resource-pool-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string + SnapshotName string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", snapshotNameArg), + Short: "Shows details of a snapshot", + Long: "Shows details of a snapshot.", + Args: args.SingleArg(snapshotNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Describe a snapshot with "SNAPSHOT_NAME" from resource pool with ID "yyy"`, + "stackit beta sfs snapshot describe SNAPSHOT_NAME --resource-pool-id yyy", + ), + ), + RunE: func(cmd *cobra.Command, inputArgs []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, inputArgs) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create snapshot: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool from which the snapshot should be created") + + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag) + cobra.CheckErr(err) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiGetResourcePoolSnapshotRequest { + return apiClient.GetResourcePoolSnapshot(ctx, model.ProjectId, model.Region, model.ResourcePoolId, model.SnapshotName) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotName: snapshotName, + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, resp *sfs.GetResourcePoolSnapshotResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil || resp.ResourcePoolSnapshot == nil { + p.Outputln("Resource pool snapshot response is empty") + return nil + } + + table := tables.NewTable() + + snap := *resp.ResourcePoolSnapshot + table.AddRow("NAME", utils.PtrString(snap.SnapshotName)) + table.AddSeparator() + if snap.Comment != nil { + table.AddRow("COMMENT", utils.PtrString(snap.Comment.Get())) + table.AddSeparator() + } + table.AddRow("RESOURCE POOL ID", utils.PtrString(snap.ResourcePoolId)) + table.AddSeparator() + table.AddRow("SIZE (GB)", utils.PtrString(snap.SizeGigabytes)) + table.AddSeparator() + table.AddRow("LOGICAL SIZE (GB)", utils.PtrString(snap.LogicalSizeGigabytes)) + table.AddSeparator() + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(snap.CreatedAt)) + table.AddSeparator() + + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/snapshot/describe/describe_test.go b/internal/cmd/beta/sfs/snapshot/describe/describe_test.go new file mode 100644 index 000000000..c477bceb7 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/describe/describe_test.go @@ -0,0 +1,234 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testResourcePoolId = uuid.NewString() +var testSnapshotName = "testSnapshotName" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotName, + } + 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, + regionFlag: testRegion, + + resourcePoolIdFlag: testResourcePoolId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ResourcePoolId: testResourcePoolId, + SnapshotName: testSnapshotName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiGetResourcePoolSnapshotRequest)) sfs.ApiGetResourcePoolSnapshotRequest { + request := testClient.GetResourcePoolSnapshot(testCtx, testProjectId, testRegion, testResourcePoolId, testSnapshotName) + 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: "share id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "resource pool invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "" + }), + isValid: false, + }, + { + description: "resource pool invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiGetResourcePoolSnapshotRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resp *sfs.GetResourcePoolSnapshotResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + resp: &sfs.GetResourcePoolSnapshotResponse{}, + }, + wantErr: false, + }, + { + name: " set empty snapshot", + args: args{ + resp: &sfs.GetResourcePoolSnapshotResponse{ + ResourcePoolSnapshot: &sfs.GetResourcePoolSnapshotResponseResourcePoolSnapshot{}, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/snapshot/list/list.go b/internal/cmd/beta/sfs/snapshot/list/list.go new file mode 100644 index 000000000..cfc0347b8 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/list/list.go @@ -0,0 +1,149 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sfs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + resourcePoolIdFlag = "resource-pool-id" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ResourcePoolId string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all snapshots of a resource pool", + Long: "Lists all snapshots of a resource pool.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all snapshots of a resource pool with ID "xxx"`, + "$ stackit beta sfs snapshot list --resource-pool-id xxx", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list snapshot: %w", err) + } + + // Truncate output + items := utils.GetSliceFromPointer(resp.ResourcePoolSnapshots) + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, items) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), resourcePoolIdFlag, "The resource pool from which the snapshot should be created") + cmd.Flags().Int64(limitFlag, 0, "Number of snapshots to list") + + err := flags.MarkFlagsRequired(cmd, resourcePoolIdFlag) + cobra.CheckErr(err) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sfs.APIClient) sfs.ApiListResourcePoolSnapshotsRequest { + req := apiClient.ListResourcePoolSnapshots(ctx, model.ProjectId, model.Region, model.ResourcePoolId) + return req +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ResourcePoolId: flags.FlagToStringValue(p, cmd, resourcePoolIdFlag), + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, resp []sfs.ResourcePoolSnapshot) error { + return p.OutputResult(outputFormat, resp, func() error { + if len(resp) == 0 { + p.Outputln("No snapshots found") + return nil + } + table := tables.NewTable() + table.SetHeader( + "NAME", + "COMMENT", + "RESOURCE POOL ID", + "SIZE (GB)", + "LOGICAL SIZE (GB)", + "CREATED AT", + ) + + for _, snap := range resp { + var comment string + if snap.Comment != nil { + comment = utils.PtrString(snap.Comment.Get()) + } + table.AddRow( + utils.PtrString(snap.SnapshotName), + comment, + utils.PtrString(snap.ResourcePoolId), + utils.PtrString(snap.SizeGigabytes), + utils.PtrString(snap.LogicalSizeGigabytes), + utils.ConvertTimePToDateTimeString(snap.CreatedAt), + ) + } + + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/beta/sfs/snapshot/list/list_test.go b/internal/cmd/beta/sfs/snapshot/list/list_test.go new file mode 100644 index 000000000..5678f3a55 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/list/list_test.go @@ -0,0 +1,174 @@ +package list + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +var projectIdFlag = globalflags.ProjectIdFlag +var regionFlag = globalflags.RegionFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sfs.APIClient{} + +var testProjectId = uuid.NewString() +var testRegion = "eu01" + +var testResourcePoolId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + regionFlag: testRegion, + + resourcePoolIdFlag: testResourcePoolId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ResourcePoolId: testResourcePoolId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sfs.ApiListResourcePoolSnapshotsRequest)) sfs.ApiListResourcePoolSnapshotsRequest { + request := testClient.ListResourcePoolSnapshots(testCtx, testProjectId, testRegion, testResourcePoolId) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flags", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "invalid resource pool id 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid resource pool id 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[resourcePoolIdFlag] = "invalid-resource-pool-id" + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sfs.ApiListResourcePoolSnapshotsRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resp []sfs.ResourcePoolSnapshot + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + resp: []sfs.ResourcePoolSnapshot{}, + }, + wantErr: false, + }, + { + name: "set empty snapshot", + args: args{ + resp: []sfs.ResourcePoolSnapshot{{}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/beta/sfs/snapshot/snapshot.go b/internal/cmd/beta/sfs/snapshot/snapshot.go new file mode 100644 index 000000000..aab304c52 --- /dev/null +++ b/internal/cmd/beta/sfs/snapshot/snapshot.go @@ -0,0 +1,32 @@ +package snapshot + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sfs/snapshot/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "snapshot", + Short: "Provides functionality for SFS snapshots", + Long: "Provides functionality for SFS snapshots.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/beta/sqlserverflex/database/create/create.go b/internal/cmd/beta/sqlserverflex/database/create/create.go index fe137c435..6539b79e1 100644 --- a/internal/cmd/beta/sqlserverflex/database/create/create.go +++ b/internal/cmd/beta/sqlserverflex/database/create/create.go @@ -2,10 +2,12 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" "github.com/spf13/cobra" ) @@ -33,7 +34,7 @@ type inputModel struct { Owner string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("create %s", databaseNameArg), Short: "Creates a SQLServer Flex database", @@ -49,37 +50,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create database %q? (This cannot be undone)", model.DatabaseName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create database %q? (This cannot be undone)", model.DatabaseName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API req := buildRequest(ctx, model, apiClient) - s := spinner.New(p) - s.Start("Creating database") - resp, err := req.Execute() + resp, err := spinner.Run2(params.Printer, "Creating database", func() (*sqlserverflex.CreateDatabaseResponse, error) { + return req.Execute() + }) if err != nil { - s.StopWithError() return fmt.Errorf("create SQLServer Flex database: %w", err) } - s.Stop() - return outputResult(p, model.OutputFormat, model.DatabaseName, resp) + return outputResult(params.Printer, model.OutputFormat, model.DatabaseName, resp) }, } configureFlags(cmd) @@ -108,15 +105,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Owner: flags.FlagToStringValue(p, cmd, ownerFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -136,25 +125,9 @@ func outputResult(p *print.Printer, outputFormat, databaseName string, resp *sql if resp == nil { return fmt.Errorf("sqlserverflex response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex database: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex database: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created database %q\n", databaseName) return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/database/create/create_test.go b/internal/cmd/beta/sqlserverflex/database/create/create_test.go index ada824cb6..e951a1506 100644 --- a/internal/cmd/beta/sqlserverflex/database/create/create_test.go +++ b/internal/cmd/beta/sqlserverflex/database/create/create_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -176,54 +180,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -281,7 +238,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.databaseName, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/database/database.go b/internal/cmd/beta/sqlserverflex/database/database.go index 3fbd641ce..75113d255 100644 --- a/internal/cmd/beta/sqlserverflex/database/database.go +++ b/internal/cmd/beta/sqlserverflex/database/database.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/database/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/database/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "database", Short: "Provides functionality for SQLServer Flex databases", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/beta/sqlserverflex/database/delete/delete.go b/internal/cmd/beta/sqlserverflex/database/delete/delete.go index d47633cf3..ae8148ec2 100644 --- a/internal/cmd/beta/sqlserverflex/database/delete/delete.go +++ b/internal/cmd/beta/sqlserverflex/database/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +31,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", databaseNameArg), Short: "Deletes a SQLServer Flex database", @@ -45,37 +47,34 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete database %q? (This cannot be undone)", model.DatabaseName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete database %q? (This cannot be undone)", model.DatabaseName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API req := buildRequest(ctx, model, apiClient) - s := spinner.New(p) - s.Start("Deleting database") - err = req.Execute() + err = spinner.Run(params.Printer, "Deleting database", func() error { + err := req.Execute() + return err + }) if err != nil { - s.StopWithError() return fmt.Errorf("delete SQLServer Flex database: %w", err) } - s.Stop() - p.Info("Deleted database %q\n", model.DatabaseName) + params.Printer.Info("Deleted database %q\n", model.DatabaseName) return nil }, } @@ -103,15 +102,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/beta/sqlserverflex/database/delete/delete_test.go b/internal/cmd/beta/sqlserverflex/database/delete/delete_test.go index 315341040..449731141 100644 --- a/internal/cmd/beta/sqlserverflex/database/delete/delete_test.go +++ b/internal/cmd/beta/sqlserverflex/database/delete/delete_test.go @@ -4,10 +4,11 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" @@ -159,54 +160,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/beta/sqlserverflex/database/describe/describe.go b/internal/cmd/beta/sqlserverflex/database/describe/describe.go index ed2caecf7..6fa5394fe 100644 --- a/internal/cmd/beta/sqlserverflex/database/describe/describe.go +++ b/internal/cmd/beta/sqlserverflex/database/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) const ( @@ -31,7 +32,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", databaseNameArg), Short: "Shows details of an SQLServer Flex database", @@ -47,12 +48,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -64,7 +65,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read SQLServer Flex database: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -92,15 +93,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -113,24 +106,8 @@ func outputResult(p *print.Printer, outputFormat string, resp *sqlserverflex.Get if resp == nil || resp.Database == nil { return fmt.Errorf("database response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex database: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex database: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { database := resp.Database table := tables.NewTable() table.AddRow("ID", utils.PtrString(database.Id)) @@ -156,5 +133,5 @@ func outputResult(p *print.Printer, outputFormat string, resp *sqlserverflex.Get } return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/database/describe/describe_test.go b/internal/cmd/beta/sqlserverflex/database/describe/describe_test.go index b55faf4a2..176741037 100644 --- a/internal/cmd/beta/sqlserverflex/database/describe/describe_test.go +++ b/internal/cmd/beta/sqlserverflex/database/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -158,54 +162,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -269,7 +226,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/database/list/list.go b/internal/cmd/beta/sqlserverflex/database/list/list.go index 6d660f8a1..3287a7981 100644 --- a/internal/cmd/beta/sqlserverflex/database/list/list.go +++ b/internal/cmd/beta/sqlserverflex/database/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) const ( @@ -31,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all SQLServer Flex databases", @@ -48,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 SQLServer Flex databases of instance with ID "xxx"`, "$ stackit beta sqlserverflex database list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,23 +68,20 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get SQLServer Flex databases: %w", err) } - if resp.Databases == nil || len(*resp.Databases) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No databases found for instance %s on project %s\n", model.InstanceId, projectLabel) - return nil + databases := resp.GetDatabases() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } - databases := *resp.Databases // Truncate output if model.Limit != nil && len(databases) > int(*model.Limit) { databases = databases[:*model.Limit] } - return outputResult(p, model.OutputFormat, databases) + return outputResult(params.Printer, model.OutputFormat, model.InstanceId, projectLabel, databases) }, } @@ -99,7 +97,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -119,15 +117,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -136,25 +126,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverfl return req } -func outputResult(p *print.Printer, outputFormat string, databases []sqlserverflex.Database) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(databases, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex database list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(databases, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex database list: %w", err) +func outputResult(p *print.Printer, outputFormat, instanceId, projectLabel string, databases []sqlserverflex.Database) error { + return p.OutputResult(outputFormat, databases, func() error { + if len(databases) == 0 { + p.Outputf("No databases found for instance %s on project %s\n", instanceId, projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME") for i := range databases { @@ -167,5 +145,5 @@ func outputResult(p *print.Printer, outputFormat string, databases []sqlserverfl } return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/database/list/list_test.go b/internal/cmd/beta/sqlserverflex/database/list/list_test.go index 1b10b22a8..bd9e6678b 100644 --- a/internal/cmd/beta/sqlserverflex/database/list/list_test.go +++ b/internal/cmd/beta/sqlserverflex/database/list/list_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) type testCtxKey struct{} @@ -20,7 +23,8 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &sqlserverflex.APIClient{} var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() -var testRegion = "eu01" + +const testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ @@ -62,6 +66,7 @@ func fixtureRequest(mods ...func(request *sqlserverflex.ApiListDatabasesRequest) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -137,48 +142,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -214,6 +178,8 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + instanceId string + projectLabel string databases []sqlserverflex.Database } tests := []struct { @@ -235,10 +201,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.databases); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceId, tt.args.projectLabel, tt.args.databases); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/beta/sqlserverflex/instance/create/create.go b/internal/cmd/beta/sqlserverflex/instance/create/create.go index 904b2f6f8..34499a3bc 100644 --- a/internal/cmd/beta/sqlserverflex/instance/create/create.go +++ b/internal/cmd/beta/sqlserverflex/instance/create/create.go @@ -2,11 +2,11 @@ package create import ( "context" - "encoding/json" "errors" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -65,7 +65,7 @@ type inputModel struct { RetentionDays *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a SQLServer Flex instance", @@ -81,34 +81,32 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta sqlserverflex instance create --name my-instance --flavor-id xxx`), examples.NewExample( `Create a SQLServer Flex instance with name "my-instance", specify flavor by CPU and RAM, set storage size to 20 GB, and restrict access to a specific range of IP addresses. Other parameters are set to default values`, - `$ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4 --storage-size 20 --acl 1.2.3.0/24`), + `$ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4 --storage-size 20 --acl 1.2.3.0/24`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a SQLServer Flex instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a SQLServer Flex instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -124,16 +122,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for SQLServer Flex instance creation: %w", err) } - s.Stop() } - return outputResult(p, model, projectLabel, resp) + return outputResult(params.Printer, model, projectLabel, resp) }, } configureFlags(cmd) @@ -157,7 +155,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -195,15 +193,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { RetentionDays: flags.FlagToInt64Pointer(p, cmd, retentionDaysFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -271,29 +261,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp if resp == nil { return fmt.Errorf("sqlserverflex response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServerFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServerFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Created" if model.Async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/instance/create/create_test.go b/internal/cmd/beta/sqlserverflex/instance/create/create_test.go index eaf4a5463..5916b6b34 100644 --- a/internal/cmd/beta/sqlserverflex/instance/create/create_test.go +++ b/internal/cmd/beta/sqlserverflex/instance/create/create_test.go @@ -5,13 +5,17 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) type testCtxKey struct{} @@ -130,6 +134,7 @@ func fixturePayload(mods ...func(payload *sqlserverflex.CreateInstancePayload)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -250,56 +255,9 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + aclFlag: tt.aclValues, + }, tt.isValid) }) } } @@ -524,7 +482,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/instance/delete/delete.go b/internal/cmd/beta/sqlserverflex/instance/delete/delete.go index c0456b08d..429dbb364 100644 --- a/internal/cmd/beta/sqlserverflex/instance/delete/delete.go +++ b/internal/cmd/beta/sqlserverflex/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a SQLServer Flex instance", @@ -41,29 +43,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -75,20 +75,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for SQLServer Flex instance deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -108,15 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/beta/sqlserverflex/instance/delete/delete_test.go b/internal/cmd/beta/sqlserverflex/instance/delete/delete_test.go index 753379175..fe66b190b 100644 --- a/internal/cmd/beta/sqlserverflex/instance/delete/delete_test.go +++ b/internal/cmd/beta/sqlserverflex/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -138,54 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/beta/sqlserverflex/instance/describe/describe.go b/internal/cmd/beta/sqlserverflex/instance/describe/describe.go index c5d8e68e1..b1b4f167c 100644 --- a/internal/cmd/beta/sqlserverflex/instance/describe/describe.go +++ b/internal/cmd/beta/sqlserverflex/instance/describe/describe.go @@ -2,11 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +29,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of a SQLServer Flex instance", @@ -45,12 +45,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -62,7 +62,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read SQLServer Flex instance: %w", err) } - return outputResult(p, model.OutputFormat, resp.Item) + return outputResult(params.Printer, model.OutputFormat, resp.Item) }, } return cmd @@ -81,15 +81,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -102,24 +94,8 @@ func outputResult(p *print.Printer, outputFormat string, instance *sqlserverflex if instance == nil { return fmt.Errorf("instance response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex instance: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { var acls string if instance.Acl != nil && instance.Acl.HasItems() { aclsArray := *instance.Acl.Items @@ -157,5 +133,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *sqlserverflex } return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/instance/describe/describe_test.go b/internal/cmd/beta/sqlserverflex/instance/describe/describe_test.go index 29edcede2..c653217bf 100644 --- a/internal/cmd/beta/sqlserverflex/instance/describe/describe_test.go +++ b/internal/cmd/beta/sqlserverflex/instance/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -137,54 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -241,7 +198,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/instance/instance.go b/internal/cmd/beta/sqlserverflex/instance/instance.go index a74e41085..9d8784bc7 100644 --- a/internal/cmd/beta/sqlserverflex/instance/instance.go +++ b/internal/cmd/beta/sqlserverflex/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for SQLServer Flex instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/beta/sqlserverflex/instance/list/list.go b/internal/cmd/beta/sqlserverflex/instance/list/list.go index 18666a7d5..379622006 100644 --- a/internal/cmd/beta/sqlserverflex/instance/list/list.go +++ b/internal/cmd/beta/sqlserverflex/instance/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all SQLServer Flex instances", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 SQLServer Flex instances`, "$ stackit beta sqlserverflex instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,23 +66,20 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get SQLServer Flex instances: %w", err) } - if resp.Items == nil || len(*resp.Items) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No instances found for project %q\n", projectLabel) - return nil + instances := resp.GetItems() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } - instances := *resp.Items // Truncate output if model.Limit != nil && len(instances) > int(*model.Limit) { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) }, } @@ -93,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverfl return req } -func outputResult(p *print.Printer, outputFormat string, instances []sqlserverflex.InstanceListInstance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex instance list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []sqlserverflex.InstanceListInstance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "STATUS") for i := range instances { @@ -164,5 +142,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []sqlserverfl } return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/instance/list/list_test.go b/internal/cmd/beta/sqlserverflex/instance/list/list_test.go index 094774724..966d4f14f 100644 --- a/internal/cmd/beta/sqlserverflex/instance/list/list_test.go +++ b/internal/cmd/beta/sqlserverflex/instance/list/list_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) type testCtxKey struct{} @@ -19,7 +22,8 @@ type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &sqlserverflex.APIClient{} var testProjectId = uuid.NewString() -var testRegion = "eu01" + +const testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ @@ -59,6 +63,7 @@ func fixtureRequest(mods ...func(request *sqlserverflex.ApiListInstancesRequest) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -113,48 +118,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -190,6 +154,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string instances []sqlserverflex.InstanceListInstance } tests := []struct { @@ -211,10 +176,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/beta/sqlserverflex/instance/update/update.go b/internal/cmd/beta/sqlserverflex/instance/update/update.go index 39c5989dc..3a5c497b8 100644 --- a/internal/cmd/beta/sqlserverflex/instance/update/update.go +++ b/internal/cmd/beta/sqlserverflex/instance/update/update.go @@ -2,11 +2,11 @@ package update import ( "context" - "encoding/json" "errors" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -60,7 +60,7 @@ type inputModel struct { Version *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates a SQLServer Flex instance", @@ -77,29 +77,27 @@ func NewCmd(p *print.Printer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -115,16 +113,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for SQLServer Flex instance update: %w", err) } - s.Stop() } - return outputResult(p, model, instanceLabel, resp) + return outputResult(params.Printer, model, instanceLabel, resp) }, } configureFlags(cmd) @@ -182,15 +180,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -255,29 +245,12 @@ func outputResult(p *print.Printer, model *inputModel, instanceLabel string, res if resp == nil { return fmt.Errorf("instance response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal update SQLServerFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal update SQLServerFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Updated" if model.Async { operationState = "Triggered update of" } p.Info("%s instance %q\n", operationState, instanceLabel) return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/instance/update/update_test.go b/internal/cmd/beta/sqlserverflex/instance/update/update_test.go index 4c9c92dd5..925b80494 100644 --- a/internal/cmd/beta/sqlserverflex/instance/update/update_test.go +++ b/internal/cmd/beta/sqlserverflex/instance/update/update_test.go @@ -5,13 +5,16 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) type testCtxKey struct{} @@ -281,7 +284,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -520,7 +523,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/options/options.go b/internal/cmd/beta/sqlserverflex/options/options.go index a4b815a44..236dab10d 100644 --- a/internal/cmd/beta/sqlserverflex/options/options.go +++ b/internal/cmd/beta/sqlserverflex/options/options.go @@ -2,10 +2,10 @@ package options import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -87,7 +87,7 @@ type instanceDBCompatibilities struct { DBCompatibilities []sqlserverflex.MssqlDatabaseCompatibility `json:"dbCompatibilities"` } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "options", Short: "Lists SQL Server Flex options", @@ -107,21 +107,21 @@ func NewCmd(p *print.Printer) *cobra.Command { `List SQL Server Flex user roles and database compatibilities for a given instance. The IDs of existing instances can be obtained by running "$ stackit beta sqlserverflex instance list"`, "$ stackit beta sqlserverflex options --user-roles --db-compatibilities --instance-id "), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } // Call API - err = buildAndExecuteRequest(ctx, p, model, apiClient) + err = buildAndExecuteRequest(ctx, params.Printer, model, apiClient) if err != nil { return fmt.Errorf("get SQL Server Flex options: %w", err) } @@ -144,7 +144,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(instanceIdFlag, "", `The instance ID to show user roles, database collations and database compatibilities for. Only relevant when "--user-roles", "--db-collations" or "--db-compatibilities" is passed`) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) flavors := flags.FlagToBoolValue(p, cmd, flavorsFlag) @@ -189,15 +189,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -283,55 +275,35 @@ func outputResult(p *print.Printer, model *inputModel, flavors *sqlserverflex.Li } } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(options, "", " ") - if err != nil { - return fmt.Errorf("marshal SQL Server Flex options: %w", err) + return p.OutputResult(model.OutputFormat, options, func() error { + content := []tables.Table{} + if model.Flavors && len(*options.Flavors) != 0 { + content = append(content, buildFlavorsTable(*options.Flavors)) } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(options, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if model.Versions && len(*options.Versions) != 0 { + content = append(content, buildVersionsTable(*options.Versions)) + } + if model.Storages && options.Storages.Storages != nil && len(*options.Storages.Storages.StorageClasses) != 0 { + content = append(content, buildStoragesTable(*options.Storages.Storages)) + } + if model.UserRoles && len(options.UserRoles.UserRoles) != 0 { + content = append(content, buildUserRoles(options.UserRoles)) + } + if model.DBCompatibilities && len(options.DBCompatibilities.DBCompatibilities) != 0 { + content = append(content, buildDBCompatibilitiesTable(options.DBCompatibilities.DBCompatibilities)) + } + // Rendered at last because table is very long + if model.DBCollations && len(options.DBCollations.DBCollations) != 0 { + content = append(content, buildDBCollationsTable(options.DBCollations.DBCollations)) + } + + err := tables.DisplayTables(p, content) if err != nil { - return fmt.Errorf("marshal SQL Server Flex options: %w", err) + return fmt.Errorf("display output: %w", err) } - p.Outputln(string(details)) return nil - default: - return outputResultAsTable(p, model, options) - } -} - -func outputResultAsTable(p *print.Printer, model *inputModel, options *options) error { - content := []tables.Table{} - if model.Flavors && len(*options.Flavors) != 0 { - content = append(content, buildFlavorsTable(*options.Flavors)) - } - if model.Versions && len(*options.Versions) != 0 { - content = append(content, buildVersionsTable(*options.Versions)) - } - if model.Storages && options.Storages.Storages != nil && len(*options.Storages.Storages.StorageClasses) != 0 { - content = append(content, buildStoragesTable(*options.Storages.Storages)) - } - if model.UserRoles && len(options.UserRoles.UserRoles) != 0 { - content = append(content, buildUserRoles(options.UserRoles)) - } - if model.DBCompatibilities && len(options.DBCompatibilities.DBCompatibilities) != 0 { - content = append(content, buildDBCompatibilitiesTable(options.DBCompatibilities.DBCompatibilities)) - } - // Rendered at last because table is very long - if model.DBCollations && len(options.DBCollations.DBCollations) != 0 { - content = append(content, buildDBCollationsTable(options.DBCollations.DBCollations)) - } - - err := tables.DisplayTables(p, content) - if err != nil { - return fmt.Errorf("display output: %w", err) - } - - return nil + }) } func buildFlavorsTable(flavors []sqlserverflex.InstanceFlavorEntry) tables.Table { diff --git a/internal/cmd/beta/sqlserverflex/options/options_test.go b/internal/cmd/beta/sqlserverflex/options/options_test.go index 5e76ad539..716f9a5bd 100644 --- a/internal/cmd/beta/sqlserverflex/options/options_test.go +++ b/internal/cmd/beta/sqlserverflex/options/options_test.go @@ -5,12 +5,15 @@ import ( "fmt" "testing" - "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) type testCtxKey struct{} @@ -157,6 +160,7 @@ func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -262,46 +266,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -472,7 +437,7 @@ func TestBuildAndExecuteRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) p.Cmd = cmd client := &sqlServerFlexClientMocked{ listFlavorsFails: tt.listFlavorsFails, @@ -544,7 +509,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.flavors, tt.args.versions, tt.args.storages, tt.args.userRoles, tt.args.dbCollations, tt.args.dbCompatibilities); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/sqlserverflex.go b/internal/cmd/beta/sqlserverflex/sqlserverflex.go index a65ca22e8..29404a045 100644 --- a/internal/cmd/beta/sqlserverflex/sqlserverflex.go +++ b/internal/cmd/beta/sqlserverflex/sqlserverflex.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/options" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/user" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "sqlserverflex", Short: "Provides functionality for SQLServer Flex", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(database.NewCmd(p)) - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(options.NewCmd(p)) - cmd.AddCommand(user.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(database.NewCmd(params)) + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(options.NewCmd(params)) + cmd.AddCommand(user.NewCmd(params)) } diff --git a/internal/cmd/beta/sqlserverflex/user/create/create.go b/internal/cmd/beta/sqlserverflex/user/create/create.go index 0cd99180c..109a0cab6 100644 --- a/internal/cmd/beta/sqlserverflex/user/create/create.go +++ b/internal/cmd/beta/sqlserverflex/user/create/create.go @@ -2,12 +2,14 @@ package create import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/client" sqlserverflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) const ( @@ -34,7 +35,7 @@ type inputModel struct { Roles *[]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a SQLServer Flex user", @@ -43,7 +44,7 @@ func NewCmd(p *print.Printer) *cobra.Command { "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 beta sqlserverflex user reset-password USER_ID --instance-id INSTANCE_ID", - "Please refer to https://docs.stackit.cloud/stackit/en/creating-logins-and-users-in-sqlserver-flex-instances-210862358.html for additional information.", + "Please refer to https://docs.stackit.cloud/products/databases/sqlserver-flex/how-tos/create-logins-and-users-in-sqlserver-flex-instances/ for additional information.", "The allowed user roles for your instance can be obtained by running:", " $ stackit beta sqlserverflex options --user-roles --instance-id INSTANCE_ID", ), @@ -56,31 +57,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "##STACKIT_LoginManager##,##STACKIT_DatabaseManager##"`), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -91,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } user := resp.Item - return outputResult(p, model, instanceLabel, user) + return outputResult(params.Printer, model, instanceLabel, user) }, } @@ -108,7 +107,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -121,15 +120,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Roles: flags.FlagToStringSlicePointer(p, cmd, rolesFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -147,24 +138,7 @@ func outputResult(p *print.Printer, model *inputModel, instanceLabel string, use if user == nil { return fmt.Errorf("user response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, user, func() error { p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, utils.PtrString(user.Id)) p.Outputf("Username: %s\n", utils.PtrString(user.Username)) p.Outputf("Password: %s\n", utils.PtrString(user.Password)) @@ -182,5 +156,5 @@ func outputResult(p *print.Printer, model *inputModel, instanceLabel string, use } return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/user/create/create_test.go b/internal/cmd/beta/sqlserverflex/user/create/create_test.go index a53878b3d..fc32a2312 100644 --- a/internal/cmd/beta/sqlserverflex/user/create/create_test.go +++ b/internal/cmd/beta/sqlserverflex/user/create/create_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) type testCtxKey struct{} @@ -69,6 +72,7 @@ func fixtureRequest(mods ...func(request *sqlserverflex.ApiCreateUserRequest)) s func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -138,48 +142,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -247,7 +210,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.instanceLabel, tt.args.user); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/user/delete/delete.go b/internal/cmd/beta/sqlserverflex/user/delete/delete.go index b85272ba3..c1dd038d0 100644 --- a/internal/cmd/beta/sqlserverflex/user/delete/delete.go +++ b/internal/cmd/beta/sqlserverflex/user/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", userIdArg), Short: "Deletes a SQLServer Flex user", @@ -46,35 +48,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } userLabel, err := sqlserverflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user name: %v", err) userLabel = model.UserId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,7 +84,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete SQLServer Flex user: %w", err) } - p.Info("Deleted user %q of instance %q\n", userLabel, instanceLabel) + params.Printer.Info("Deleted user %q of instance %q\n", userLabel, instanceLabel) return nil }, } @@ -113,15 +113,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/beta/sqlserverflex/user/delete/delete_test.go b/internal/cmd/beta/sqlserverflex/user/delete/delete_test.go index 79053edf7..9220bcbfc 100644 --- a/internal/cmd/beta/sqlserverflex/user/delete/delete_test.go +++ b/internal/cmd/beta/sqlserverflex/user/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -153,54 +153,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/beta/sqlserverflex/user/describe/describe.go b/internal/cmd/beta/sqlserverflex/user/describe/describe.go index e9822bdd0..bdfe47fc1 100644 --- a/internal/cmd/beta/sqlserverflex/user/describe/describe.go +++ b/internal/cmd/beta/sqlserverflex/user/describe/describe.go @@ -2,11 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -34,7 +34,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", userIdArg), Short: "Shows details of a SQLServer Flex user", @@ -54,13 +54,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -72,7 +72,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get SQLServer Flex user: %w", err) } - return outputResult(p, model.OutputFormat, resp.Item) + return outputResult(params.Printer, model.OutputFormat, resp.Item) }, } @@ -101,15 +101,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -122,24 +114,8 @@ func outputResult(p *print.Printer, outputFormat string, user *sqlserverflex.Use if user == nil { return fmt.Errorf("user response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex user: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, user, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(user.Id)) table.AddSeparator() @@ -167,5 +143,5 @@ func outputResult(p *print.Printer, outputFormat string, user *sqlserverflex.Use } return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/user/describe/describe_test.go b/internal/cmd/beta/sqlserverflex/user/describe/describe_test.go index c0d6be049..0145cd339 100644 --- a/internal/cmd/beta/sqlserverflex/user/describe/describe_test.go +++ b/internal/cmd/beta/sqlserverflex/user/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -152,54 +156,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -256,7 +213,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.user); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/user/list/list.go b/internal/cmd/beta/sqlserverflex/user/list/list.go index c4fcd2152..6218b5d15 100644 --- a/internal/cmd/beta/sqlserverflex/user/list/list.go +++ b/internal/cmd/beta/sqlserverflex/user/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( sqlserverflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all SQLServer Flex users of an instance", @@ -49,15 +50,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit beta sqlserverflex user list --instance-id xxx --limit 10"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,23 +69,20 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get SQLServer Flex users: %w", err) } - if resp.Items == nil || len(*resp.Items) == 0 { - instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId, model.Region) - if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) - instanceLabel = *model.InstanceId - } - p.Info("No users found for instance %q\n", instanceLabel) - return nil + users := resp.GetItems() + + instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = *model.InstanceId } - users := *resp.Items // Truncate output if model.Limit != nil && len(users) > int(*model.Limit) { users = users[:*model.Limit] } - return outputResult(p, model.OutputFormat, users) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, users) }, } @@ -100,7 +98,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -120,15 +118,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -137,25 +127,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverfl return req } -func outputResult(p *print.Printer, outputFormat string, users []sqlserverflex.InstanceListUser) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(users, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex user list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(users, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex user list: %w", err) +func outputResult(p *print.Printer, outputFormat, instanceLabel string, users []sqlserverflex.InstanceListUser) error { + return p.OutputResult(outputFormat, users, func() error { + if len(users) == 0 { + p.Outputf("No users found for instance %q\n", instanceLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID", "USERNAME") for i := range users { @@ -171,5 +149,5 @@ func outputResult(p *print.Printer, outputFormat string, users []sqlserverflex.I } return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/user/list/list_test.go b/internal/cmd/beta/sqlserverflex/user/list/list_test.go index cc3dabc34..37fa59f32 100644 --- a/internal/cmd/beta/sqlserverflex/user/list/list_test.go +++ b/internal/cmd/beta/sqlserverflex/user/list/list_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) type testCtxKey struct{} @@ -20,7 +23,8 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &sqlserverflex.APIClient{} var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() -var testRegion = "eu01" + +const testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ @@ -62,6 +66,7 @@ func fixtureRequest(mods ...func(request *sqlserverflex.ApiListUsersRequest)) sq func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -130,48 +135,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -206,8 +170,9 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { - outputFormat string - users []sqlserverflex.InstanceListUser + outputFormat string + instanceLabel string + users []sqlserverflex.InstanceListUser } tests := []struct { name string @@ -228,10 +193,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.users); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.users); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password.go b/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password.go index 182a9e147..f76922fc8 100644 --- a/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password.go +++ b/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password.go @@ -2,10 +2,10 @@ package resetpassword import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +33,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("reset-password %s", userIdArg), Short: "Resets the password of a SQLServer Flex user", @@ -49,35 +49,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } userLabel, err := sqlserverflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user name: %v", err) userLabel = model.UserId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to reset the password of user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to reset the password of user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -87,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("reset SQLServer Flex user password: %w", err) } - return outputResult(p, model.OutputFormat, userLabel, instanceLabel, user.Item) + return outputResult(params.Printer, model.OutputFormat, userLabel, instanceLabel, user.Item) }, } @@ -116,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -137,24 +127,8 @@ func outputResult(p *print.Printer, outputFormat, userLabel, instanceLabel strin if user == nil { return fmt.Errorf("single user response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal SQLServer Flex reset password: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SQLServer Flex reset password: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, user, func() error { p.Outputf("Reset password for user %q of instance %q\n\n", userLabel, instanceLabel) p.Outputf("Username: %s\n", utils.PtrString(user.Username)) p.Outputf("New password: %s\n", utils.PtrString(user.Password)) @@ -162,5 +136,5 @@ func outputResult(p *print.Printer, outputFormat, userLabel, instanceLabel strin p.Outputf("New URI: %s\n", *user.Uri) } return nil - } + }) } diff --git a/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password_test.go b/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password_test.go index 8eda0631f..7be0952a8 100644 --- a/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password_test.go +++ b/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -152,54 +156,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -258,7 +215,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.userLabel, tt.args.instanceLabel, tt.args.user); (err != nil) != tt.wantErr { diff --git a/internal/cmd/beta/sqlserverflex/user/user.go b/internal/cmd/beta/sqlserverflex/user/user.go index 9426f3bbf..572a9ea52 100644 --- a/internal/cmd/beta/sqlserverflex/user/user.go +++ b/internal/cmd/beta/sqlserverflex/user/user.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/user/list" resetpassword "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/user/reset-password" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "user", Short: "Provides functionality for SQLServer Flex users", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(resetpassword.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(resetpassword.NewCmd(params)) } diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go index a96355a54..3000a8ac1 100644 --- a/internal/cmd/config/config.go +++ b/internal/cmd/config/config.go @@ -3,18 +3,19 @@ package config import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/cmd/config/list" "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile" "github.com/stackitcloud/stackit-cli/internal/cmd/config/set" "github.com/stackitcloud/stackit-cli/internal/cmd/config/unset" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "config", Short: "Provides functionality for CLI configuration options", @@ -28,13 +29,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(set.NewCmd(p)) - cmd.AddCommand(unset.NewCmd(p)) - cmd.AddCommand(profile.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(set.NewCmd(params)) + cmd.AddCommand(unset.NewCmd(params)) + cmd.AddCommand(profile.NewCmd(params)) } diff --git a/internal/cmd/config/list/list.go b/internal/cmd/config/list/list.go index 7fd7b51ce..99112637a 100644 --- a/internal/cmd/config/list/list.go +++ b/internal/cmd/config/list/list.go @@ -4,12 +4,15 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "slices" "sort" "strconv" "strings" "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -25,7 +28,7 @@ type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists the current CLI configuration values", @@ -50,14 +53,14 @@ func NewCmd(p *print.Printer) *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { configData := viper.AllSettings() - model := parseInput(p, cmd) + model := parseInput(params.Printer, cmd) activeProfile, err := config.GetProfile() if err != nil { return fmt.Errorf("get profile: %w", err) } - return outputResult(p, model.OutputFormat, configData, activeProfile) + return outputResult(params.Printer, model.OutputFormat, configData, activeProfile) }, } return cmd diff --git a/internal/cmd/config/list/list_test.go b/internal/cmd/config/list/list_test.go index d32d5d76a..2129c29d9 100644 --- a/internal/cmd/config/list/list_test.go +++ b/internal/cmd/config/list/list_test.go @@ -3,6 +3,8 @@ package list import ( "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) @@ -24,7 +26,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.configData, tt.args.activeProfile); (err != nil) != tt.wantErr { diff --git a/internal/cmd/config/profile/create/create.go b/internal/cmd/config/profile/create/create.go index ceec4ee04..e87ed5838 100644 --- a/internal/cmd/config/profile/create/create.go +++ b/internal/cmd/config/profile/create/create.go @@ -3,6 +3,8 @@ package create import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -17,27 +19,30 @@ import ( const ( profileArg = "PROFILE" - noSetFlag = "no-set" - fromEmptyProfile = "empty" + noSetFlag = "no-set" + ignoreExistingFlag = "ignore-existing" + fromEmptyProfile = "empty" ) type inputModel struct { *globalflags.GlobalFlagModel NoSet bool + IgnoreExisting bool FromEmptyProfile bool Profile string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("create %s", profileArg), Short: "Creates a CLI configuration profile", - Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", "Creates a CLI configuration profile based on the currently active profile and sets it as active.", `The profile name can be provided via the STACKIT_CLI_PROFILE environment variable or as an argument in this command.`, "The environment variable takes precedence over the argument.", "If you do not want to set the profile as active, use the --no-set flag.", "If you want to create the new profile with the initial default configurations, use the --empty flag.", + "If you want to create the new profile and ignore the error for an already existing profile, use the --ignore-existing flag.", ), Args: args.SingleArg(profileArg, nil), Example: examples.Build( @@ -49,30 +54,30 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit config profile create my-profile --empty --no-set"), ), RunE: func(cmd *cobra.Command, args []string) error { - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } - err = config.CreateProfile(p, model.Profile, !model.NoSet, model.FromEmptyProfile) + err = config.CreateProfile(params.Printer, model.Profile, !model.NoSet, model.IgnoreExisting, model.FromEmptyProfile) if err != nil { return fmt.Errorf("create profile: %w", err) } if model.NoSet { - p.Info("Successfully created profile %q\n", model.Profile) + params.Printer.Info("Successfully created profile %q\n", model.Profile) return nil } - p.Info("Successfully created and set active profile to %q\n", model.Profile) + params.Printer.Info("Successfully created and set active profile to %q\n", model.Profile) flow, err := auth.GetAuthFlow() if err != nil { - p.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile") - p.Warn("The active profile %q is not authenticated, please login using the 'stackit auth login' command.\n", model.Profile) + params.Printer.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile") + params.Printer.Warn("The active profile %q is not authenticated, please login using the 'stackit auth login' command.\n", model.Profile) return nil } - p.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow) + params.Printer.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow) return nil }, @@ -83,6 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(noSetFlag, false, "Do not set the profile as the active profile") + cmd.Flags().Bool(ignoreExistingFlag, false, "Suppress the error if the profile exists already. An existing profile will not be modified or overwritten") cmd.Flags().Bool(fromEmptyProfile, false, "Create the profile with the initial default configurations") } @@ -101,16 +107,9 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Profile: profile, FromEmptyProfile: flags.FlagToBoolValue(p, cmd, fromEmptyProfile), NoSet: flags.FlagToBoolValue(p, cmd, noSetFlag), + IgnoreExisting: flags.FlagToBoolValue(p, cmd, ignoreExistingFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/config/profile/create/create_test.go b/internal/cmd/config/profile/create/create_test.go index 0cc32cc9d..eaebaac64 100644 --- a/internal/cmd/config/profile/create/create_test.go +++ b/internal/cmd/config/profile/create/create_test.go @@ -4,9 +4,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - - "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) const testProfile = "test-profile" @@ -69,7 +67,7 @@ func TestParseInput(t *testing.T) { }, isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.GlobalFlagModel.Verbosity = globalflags.DebugVerbosity + model.Verbosity = globalflags.DebugVerbosity }), }, { @@ -103,54 +101,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/config/profile/delete/delete.go b/internal/cmd/config/profile/delete/delete.go index 992750c29..a81bf7888 100644 --- a/internal/cmd/config/profile/delete/delete.go +++ b/internal/cmd/config/profile/delete/delete.go @@ -3,6 +3,8 @@ package delete import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -23,7 +25,7 @@ type inputModel struct { Profile string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", profileArg), Short: "Delete a CLI configuration profile", @@ -38,7 +40,7 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit config profile delete my-profile"), ), RunE: func(cmd *cobra.Command, args []string) error { - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -60,18 +62,16 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get profile: %w", err) } if activeProfile == model.Profile { - p.Warn("The profile you are trying to delete is the active profile. The default profile will be set to active.\n") + params.Printer.Warn("The profile you are trying to delete is the active profile. The default profile will be set to active.\n") } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete profile %q? (This cannot be undone)", model.Profile) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete profile %q? (This cannot be undone)", model.Profile) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } - err = config.DeleteProfile(p, model.Profile) + err = config.DeleteProfile(params.Printer, model.Profile) if err != nil { return fmt.Errorf("delete profile: %w", err) } @@ -81,7 +81,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete profile authentication: %w", err) } - p.Info("Successfully deleted profile %q\n", model.Profile) + params.Printer.Info("Successfully deleted profile %q\n", model.Profile) return nil }, @@ -104,14 +104,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Profile: profile, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/config/profile/delete/delete_test.go b/internal/cmd/config/profile/delete/delete_test.go index 3919460b7..2ca839f58 100644 --- a/internal/cmd/config/profile/delete/delete_test.go +++ b/internal/cmd/config/profile/delete/delete_test.go @@ -4,9 +4,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - - "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) const testProfile = "test-profile" @@ -67,7 +65,7 @@ func TestParseInput(t *testing.T) { }, isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.GlobalFlagModel.Verbosity = globalflags.DebugVerbosity + model.Verbosity = globalflags.DebugVerbosity }), }, { @@ -79,54 +77,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/config/profile/export/export.go b/internal/cmd/config/profile/export/export.go index 9aa585971..631ca975c 100644 --- a/internal/cmd/config/profile/export/export.go +++ b/internal/cmd/config/profile/export/export.go @@ -4,6 +4,8 @@ import ( "fmt" "path/filepath" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { FilePath string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("export %s", profileNameArg), Short: "Exports a CLI configuration profile", @@ -45,17 +47,17 @@ func NewCmd(p *print.Printer) *cobra.Command { ), Args: args.SingleArg(profileNameArg, nil), RunE: func(cmd *cobra.Command, args []string) error { - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } - err = config.ExportProfile(p, model.ProfileName, model.FilePath) + err = config.ExportProfile(params.Printer, model.ProfileName, model.FilePath) if err != nil { return fmt.Errorf("could not export profile: %w", err) } - p.Info("Exported profile %q to %q\n", model.ProfileName, model.FilePath) + params.Printer.Info("Exported profile %q to %q\n", model.ProfileName, model.FilePath) return nil }, @@ -84,14 +86,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model.FilePath = filepath.Join(model.FilePath, exportFileName) } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/config/profile/export/export_test.go b/internal/cmd/config/profile/export/export_test.go index 4dffd3a22..dc67621da 100644 --- a/internal/cmd/config/profile/export/export_test.go +++ b/internal/cmd/config/profile/export/export_test.go @@ -5,9 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - - "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) const ( @@ -101,54 +99,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.argsValues) - 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(p, cmd, tt.argsValues) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/config/profile/import/import.go b/internal/cmd/config/profile/import/import.go index d57cb1929..3761ec3fa 100644 --- a/internal/cmd/config/profile/import/import.go +++ b/internal/cmd/config/profile/import/import.go @@ -2,6 +2,7 @@ package importProfile import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -9,6 +10,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" ) const ( @@ -24,7 +26,7 @@ type inputModel struct { NoSet bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "import", Short: "Imports a CLI configuration profile", @@ -40,18 +42,18 @@ func NewCmd(p *print.Printer) *cobra.Command { ), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - model, err := parseInput(p, cmd) + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } - err = config.ImportProfile(p, model.ProfileName, model.Config, !model.NoSet) + err = config.ImportProfile(params.Printer, model.ProfileName, model.Config, !model.NoSet) if err != nil { return err } - p.Info("Successfully imported profile %q\n", model.ProfileName) + params.Printer.Info("Successfully imported profile %q\n", model.ProfileName) return nil }, @@ -69,7 +71,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(cmd.MarkFlagRequired(configFlag)) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) model := &inputModel{ @@ -93,14 +95,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { } } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return model, nil } diff --git a/internal/cmd/config/profile/import/import_test.go b/internal/cmd/config/profile/import/import_test.go index 7e028ab59..e676f1b14 100644 --- a/internal/cmd/config/profile/import/import_test.go +++ b/internal/cmd/config/profile/import/import_test.go @@ -6,9 +6,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - - "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) const testProfile = "test-profile" @@ -48,6 +46,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -74,45 +73,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - if err != nil { - if !tt.isValid { - t.Fatalf("error parsing input: %v", err) - } - } - - if !tt.isValid { - t.Fatalf("did not fail on invalid input") - } - diff := cmp.Diff(tt.expectedModel, model) - if diff != "" { - t.Fatalf("Data does not match: %s", diff) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/config/profile/import/template/profile.json b/internal/cmd/config/profile/import/template/profile.json index ab56ce66b..ed2702e7e 100644 --- a/internal/cmd/config/profile/import/template/profile.json +++ b/internal/cmd/config/profile/import/template/profile.json @@ -3,6 +3,7 @@ "async": false, "authorization_custom_endpoint": "", "dns_custom_endpoint": "", + "edge_custom_endpoint": "", "iaas_custom_endpoint": "", "identity_provider_custom_client_id": "", "identity_provider_custom_well_known_configuration": "", @@ -25,9 +26,10 @@ "serverbackup_custom_endpoint": "", "service_account_custom_endpoint": "", "service_enablement_custom_endpoint": "", - "session_time_limit": "2h", + "session_time_limit": "12h", + "sfs_custom_endpoint": "", "ske_custom_endpoint": "", "sqlserverflex_custom_endpoint": "", "token_custom_endpoint": "", "verbosity": "info" -} \ No newline at end of file +} diff --git a/internal/cmd/config/profile/list/list.go b/internal/cmd/config/profile/list/list.go index 10f2e239b..1707225c1 100644 --- a/internal/cmd/config/profile/list/list.go +++ b/internal/cmd/config/profile/list/list.go @@ -1,10 +1,10 @@ package list import ( - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -20,7 +20,7 @@ type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all CLI configuration profiles", @@ -35,7 +35,7 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit config profile list --output-format json"), ), RunE: func(cmd *cobra.Command, _ []string) error { - model := parseInput(p, cmd) + model := parseInput(params.Printer, cmd) profiles, err := config.ListProfiles() if err != nil { @@ -49,7 +49,7 @@ func NewCmd(p *print.Printer) *cobra.Command { outputProfiles := buildOutput(profiles, activeProfile) - return outputResult(p, model.OutputFormat, outputProfiles) + return outputResult(params.Printer, model.OutputFormat, outputProfiles) }, } return cmd @@ -91,22 +91,7 @@ func buildOutput(profiles []string, activeProfile string) []profileInfo { } func outputResult(p *print.Printer, outputFormat string, profiles []profileInfo) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(profiles, "", " ") - if err != nil { - return fmt.Errorf("marshal config list: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(profiles, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal config list: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, profiles, func() error { table := tables.NewTable() table.SetHeader("NAME", "ACTIVE", "EMAIL") for _, profile := range profiles { @@ -127,5 +112,5 @@ func outputResult(p *print.Printer, outputFormat string, profiles []profileInfo) return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/config/profile/list/list_test.go b/internal/cmd/config/profile/list/list_test.go index 80e080c56..e9d93b147 100644 --- a/internal/cmd/config/profile/list/list_test.go +++ b/internal/cmd/config/profile/list/list_test.go @@ -3,6 +3,8 @@ package list import ( "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) @@ -23,7 +25,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.profiles); (err != nil) != tt.wantErr { diff --git a/internal/cmd/config/profile/profile.go b/internal/cmd/config/profile/profile.go index 848a60382..ab13f07cc 100644 --- a/internal/cmd/config/profile/profile.go +++ b/internal/cmd/config/profile/profile.go @@ -3,6 +3,8 @@ package profile import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/create" "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/export" @@ -11,13 +13,12 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/set" "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/unset" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "profile", Short: "Manage the CLI configuration profiles", @@ -30,16 +31,16 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(set.NewCmd(p)) - cmd.AddCommand(unset.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(importProfile.NewCmd(p)) - cmd.AddCommand(export.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(set.NewCmd(params)) + cmd.AddCommand(unset.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(importProfile.NewCmd(params)) + cmd.AddCommand(export.NewCmd(params)) } diff --git a/internal/cmd/config/profile/set/set.go b/internal/cmd/config/profile/set/set.go index ac43977b3..d5436a561 100644 --- a/internal/cmd/config/profile/set/set.go +++ b/internal/cmd/config/profile/set/set.go @@ -3,6 +3,8 @@ package set import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -23,7 +25,7 @@ type inputModel struct { Profile string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("set %s", profileArg), Short: "Set a CLI configuration profile", @@ -40,7 +42,7 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit config profile set my-profile"), ), RunE: func(cmd *cobra.Command, args []string) error { - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } @@ -53,20 +55,20 @@ func NewCmd(p *print.Printer) *cobra.Command { return &errors.SetInexistentProfile{Profile: model.Profile} } - err = config.SetProfile(p, model.Profile) + err = config.SetProfile(params.Printer, model.Profile) if err != nil { return fmt.Errorf("set profile: %w", err) } - p.Info("Successfully set active profile to %q\n", model.Profile) + params.Printer.Info("Successfully set active profile to %q\n", model.Profile) flow, err := auth.GetAuthFlow() if err != nil { - p.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile") - p.Warn("The active profile %q is not authenticated, please login using the 'stackit auth login' command.\n", model.Profile) + params.Printer.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile") + params.Printer.Warn("The active profile %q is not authenticated, please login using the 'stackit auth login' command.\n", model.Profile) return nil } - p.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow) + params.Printer.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow) return nil }, @@ -89,14 +91,6 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Profile: profile, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/config/profile/set/set_test.go b/internal/cmd/config/profile/set/set_test.go index 47f56ca0b..13d23e1be 100644 --- a/internal/cmd/config/profile/set/set_test.go +++ b/internal/cmd/config/profile/set/set_test.go @@ -4,9 +4,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - - "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) const testProfile = "test-profile" @@ -67,7 +65,7 @@ func TestParseInput(t *testing.T) { }, isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.GlobalFlagModel.Verbosity = globalflags.DebugVerbosity + model.Verbosity = globalflags.DebugVerbosity }), }, { @@ -79,54 +77,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/config/profile/unset/unset.go b/internal/cmd/config/profile/unset/unset.go index 4c06edb60..d56fcfa14 100644 --- a/internal/cmd/config/profile/unset/unset.go +++ b/internal/cmd/config/profile/unset/unset.go @@ -3,6 +3,8 @@ package unset import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" @@ -12,7 +14,7 @@ import ( "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "unset", Short: "Unset the current active CLI configuration profile", @@ -27,20 +29,20 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit config profile unset"), ), RunE: func(_ *cobra.Command, _ []string) error { - err := config.UnsetProfile(p) + err := config.UnsetProfile(params.Printer) if err != nil { return fmt.Errorf("unset profile: %w", err) } - p.Info("Profile unset successfully. The default profile will be used.\n") + params.Printer.Info("Profile unset successfully. The default profile will be used.\n") flow, err := auth.GetAuthFlow() if err != nil { - p.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile") - p.Warn("The default profile is not authenticated, please login using the 'stackit auth login' command.\n") + params.Printer.Debug(print.WarningLevel, "both keyring and text file storage failed to find a valid authentication flow for the active profile") + params.Printer.Warn("The default profile is not authenticated, please login using the 'stackit auth login' command.\n") return nil } - p.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow) + params.Printer.Debug(print.DebugLevel, "found valid authentication flow for active profile: %s", flow) return nil }, diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index b8defa692..9bb2713b5 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -4,6 +4,8 @@ import ( "fmt" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -24,6 +26,7 @@ const ( authorizationCustomEndpointFlag = "authorization-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" + edgeCustomEndpointFlag = "edge-custom-endpoint" loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" @@ -36,6 +39,7 @@ const ( redisCustomEndpointFlag = "redis-custom-endpoint" resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint" secretsManagerCustomEndpointFlag = "secrets-manager-custom-endpoint" + kmsCustomEndpointFlag = "kms-custom-endpoint" serverBackupCustomEndpointFlag = "serverbackup-custom-endpoint" serverOsUpdateCustomEndpointFlag = "server-osupdate-custom-endpoint" runCommandCustomEndpointFlag = "runcommand-custom-endpoint" @@ -45,6 +49,10 @@ const ( sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" + intakeCustomEndpointFlag = "intake-custom-endpoint" + logsCustomEndpointFlag = "logs-custom-endpoint" + sfsCustomEndpointFlag = "sfs-custom-endpoint" + cdnCustomEndpointFlag = "cdn-custom-endpoint" ) type inputModel struct { @@ -53,7 +61,7 @@ type inputModel struct { ProjectIdSet bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "set", Short: "Sets CLI configuration options", @@ -75,14 +83,14 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { - model, err := parseInput(p, cmd) + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } if model.SessionTimeLimit != nil { - p.Warn("Authenticate again to apply changes to session time limit\n") + params.Printer.Warn("Authenticate again to apply changes to session time limit\n") viper.Set(config.SessionTimeLimitKey, *model.SessionTimeLimit) } @@ -131,13 +139,14 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e } func configureFlags(cmd *cobra.Command) { - cmd.Flags().String(sessionTimeLimitFlag, "", "Maximum time before authentication is required again. After this time, you will be prompted to login again to execute commands that require authentication. Can't be larger than 24h. Requires authentication after being set to take effect. Examples: 3h, 5h30m40s (BETA: currently values greater than 2h have no effect)") + cmd.Flags().String(sessionTimeLimitFlag, "", "Maximum time before authentication is required again. After this time, you will be prompted to login again to execute commands that require authentication. Can't be larger than 24h. Requires authentication after being set to take effect. Examples: 3h, 5h30m40s") cmd.Flags().String(identityProviderCustomWellKnownConfigurationFlag, "", "Identity Provider well-known OpenID configuration URL, used for user authentication") cmd.Flags().String(identityProviderCustomClientIdFlag, "", "Identity Provider client ID, used for user authentication") cmd.Flags().String(allowedUrlDomainFlag, "", `Domain name, used for the verification of the URLs that are given in the custom identity provider endpoint and "STACKIT curl" command`) cmd.Flags().String(observabilityCustomEndpointFlag, "", "Observability API base URL, used in calls to this API") cmd.Flags().String(authorizationCustomEndpointFlag, "", "Authorization API base URL, used in calls to this API") cmd.Flags().String(dnsCustomEndpointFlag, "", "DNS API base URL, used in calls to this API") + cmd.Flags().String(edgeCustomEndpointFlag, "", "Edge API base URL, used in calls to this API") cmd.Flags().String(loadBalancerCustomEndpointFlag, "", "Load Balancer API base URL, used in calls to this API") cmd.Flags().String(logMeCustomEndpointFlag, "", "LogMe API base URL, used in calls to this API") cmd.Flags().String(mariaDBCustomEndpointFlag, "", "MariaDB API base URL, used in calls to this API") @@ -149,6 +158,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(redisCustomEndpointFlag, "", "Redis API base URL, used in calls to this API") cmd.Flags().String(resourceManagerCustomEndpointFlag, "", "Resource Manager API base URL, used in calls to this API") cmd.Flags().String(secretsManagerCustomEndpointFlag, "", "Secrets Manager API base URL, used in calls to this API") + cmd.Flags().String(kmsCustomEndpointFlag, "", "KMS API base URL, used in calls to this API") cmd.Flags().String(serviceAccountCustomEndpointFlag, "", "Service Account API base URL, used in calls to this API") cmd.Flags().String(serviceEnablementCustomEndpointFlag, "", "Service Enablement API base URL, used in calls to this API") cmd.Flags().String(serverBackupCustomEndpointFlag, "", "Server Backup API base URL, used in calls to this API") @@ -158,6 +168,10 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(sqlServerFlexCustomEndpointFlag, "", "SQLServer Flex API base URL, used in calls to this API") cmd.Flags().String(iaasCustomEndpointFlag, "", "IaaS API base URL, used in calls to this API") cmd.Flags().String(tokenCustomEndpointFlag, "", "Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication.") + cmd.Flags().String(intakeCustomEndpointFlag, "", "Intake API base URL, used in calls to this API") + cmd.Flags().String(logsCustomEndpointFlag, "", "Logs API base URL, used in calls to this API") + cmd.Flags().String(sfsCustomEndpointFlag, "", "SFS API base URL, used in calls to this API") + cmd.Flags().String(cdnCustomEndpointFlag, "", "CDN API base URL, used in calls to this API") err := viper.BindPFlag(config.SessionTimeLimitKey, cmd.Flags().Lookup(sessionTimeLimitFlag)) cobra.CheckErr(err) @@ -174,6 +188,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.DNSCustomEndpointKey, cmd.Flags().Lookup(dnsCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.EdgeCustomEndpointKey, cmd.Flags().Lookup(edgeCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.LoadBalancerCustomEndpointKey, cmd.Flags().Lookup(loadBalancerCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.LogMeCustomEndpointKey, cmd.Flags().Lookup(logMeCustomEndpointFlag)) @@ -196,6 +212,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.SecretsManagerCustomEndpointKey, cmd.Flags().Lookup(secretsManagerCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.KMSCustomEndpointKey, cmd.Flags().Lookup(kmsCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.ServerBackupCustomEndpointKey, cmd.Flags().Lookup(serverBackupCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.ServerOsUpdateCustomEndpointKey, cmd.Flags().Lookup(serverOsUpdateCustomEndpointFlag)) @@ -214,9 +232,17 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.TokenCustomEndpointKey, cmd.Flags().Lookup(tokenCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.IntakeCustomEndpointKey, cmd.Flags().Lookup(intakeCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.LogsCustomEndpointKey, cmd.Flags().Lookup(logsCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.SfsCustomEndpointKey, cmd.Flags().Lookup(sfsCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.CDNCustomEndpointKey, cmd.Flags().Lookup(cdnCustomEndpointFlag)) + cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { sessionTimeLimit, err := parseSessionTimeLimit(p, cmd) if err != nil { return nil, &errors.FlagValidationError{ @@ -229,10 +255,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { // 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(p, cmd, globalflags.ProjectIdFlag) - projectIdSet := false - if projectIdFromFlag != nil { - projectIdSet = true - } + projectIdSet := projectIdFromFlag != nil allowedUrlDomainFromFlag := flags.FlagToStringPointer(p, cmd, allowedUrlDomainFlag) allowedUrlDomainFlagValue := flags.FlagToStringValue(p, cmd, allowedUrlDomainFlag) @@ -245,15 +268,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ProjectIdSet: projectIdSet, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/config/set/set_test.go b/internal/cmd/config/set/set_test.go index 9b317b5ad..c13c84d5c 100644 --- a/internal/cmd/config/set/set_test.go +++ b/internal/cmd/config/set/set_test.go @@ -4,16 +4,16 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/google/go-cmp/cmp" "github.com/google/uuid" ) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -121,46 +121,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index 72d79aeb3..ad57f8113 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -3,6 +3,8 @@ package unset import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -20,6 +22,7 @@ const ( projectIdFlag = globalflags.ProjectIdFlag regionFlag = globalflags.RegionFlag verbosityFlag = globalflags.VerbosityFlag + assumeYesFlag = globalflags.AssumeYesFlag sessionTimeLimitFlag = "session-time-limit" identityProviderCustomWellKnownConfigurationFlag = "identity-provider-custom-well-known-configuration" @@ -28,6 +31,7 @@ const ( authorizationCustomEndpointFlag = "authorization-custom-endpoint" dnsCustomEndpointFlag = "dns-custom-endpoint" + edgeCustomEndpointFlag = "edge-custom-endpoint" loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint" logMeCustomEndpointFlag = "logme-custom-endpoint" mariaDBCustomEndpointFlag = "mariadb-custom-endpoint" @@ -40,15 +44,20 @@ const ( redisCustomEndpointFlag = "redis-custom-endpoint" resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint" secretsManagerCustomEndpointFlag = "secrets-manager-custom-endpoint" + kmsCustomEndpointFlag = "kms-custom-endpoint" serviceAccountCustomEndpointFlag = "service-account-custom-endpoint" serviceEnablementCustomEndpointFlag = "service-enablement-custom-endpoint" serverBackupCustomEndpointFlag = "serverbackup-custom-endpoint" serverOsUpdateCustomEndpointFlag = "server-osupdate-custom-endpoint" runCommandCustomEndpointFlag = "runcommand-custom-endpoint" + sfsCustomEndpointFlag = "sfs-custom-endpoint" skeCustomEndpointFlag = "ske-custom-endpoint" sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint" iaasCustomEndpointFlag = "iaas-custom-endpoint" tokenCustomEndpointFlag = "token-custom-endpoint" + intakeCustomEndpointFlag = "intake-custom-endpoint" + logsCustomEndpointFlag = "logs-custom-endpoint" + cdnCustomEndpointFlag = "cdn-custom-endpoint" ) type inputModel struct { @@ -57,6 +66,7 @@ type inputModel struct { ProjectId bool Region bool Verbosity bool + AssumeYes bool SessionTimeLimit bool IdentityProviderCustomEndpoint bool @@ -65,6 +75,7 @@ type inputModel struct { AuthorizationCustomEndpoint bool DNSCustomEndpoint bool + EdgeCustomEndpoint bool LoadBalancerCustomEndpoint bool LogMeCustomEndpoint bool MariaDBCustomEndpoint bool @@ -77,18 +88,23 @@ type inputModel struct { RedisCustomEndpoint bool ResourceManagerCustomEndpoint bool SecretsManagerCustomEndpoint bool + KMSCustomEndpoint bool ServerBackupCustomEndpoint bool ServerOsUpdateCustomEndpoint bool RunCommandCustomEndpoint bool ServiceAccountCustomEndpoint bool ServiceEnablementCustomEndpoint bool + SfsCustomEndpoint bool SKECustomEndpoint bool SQLServerFlexCustomEndpoint bool IaaSCustomEndpoint bool TokenCustomEndpoint bool + IntakeCustomEndpoint bool + LogsCustomEndpoint bool + CDNCustomEndpoint bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "unset", Short: "Unsets CLI configuration options", @@ -106,7 +122,7 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit config unset --dns-custom-endpoint"), ), RunE: func(cmd *cobra.Command, _ []string) error { - model := parseInput(p, cmd) + model := parseInput(params.Printer, cmd) if model.Async { viper.Set(config.AsyncKey, config.AsyncDefault) @@ -123,6 +139,9 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Verbosity { viper.Set(config.VerbosityKey, globalflags.VerbosityDefault) } + if model.AssumeYes { + viper.Set(config.AssumeYesKey, config.AssumeYesDefault) + } if model.SessionTimeLimit { viper.Set(config.SessionTimeLimitKey, config.SessionTimeLimitDefault) @@ -146,6 +165,9 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.DNSCustomEndpoint { viper.Set(config.DNSCustomEndpointKey, "") } + if model.EdgeCustomEndpoint { + viper.Set(config.EdgeCustomEndpointKey, "") + } if model.LoadBalancerCustomEndpoint { viper.Set(config.LoadBalancerCustomEndpointKey, "") } @@ -179,6 +201,9 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.SecretsManagerCustomEndpoint { viper.Set(config.SecretsManagerCustomEndpointKey, "") } + if model.KMSCustomEndpoint { + viper.Set(config.KMSCustomEndpointKey, "") + } if model.ServiceAccountCustomEndpoint { viper.Set(config.ServiceAccountCustomEndpointKey, "") } @@ -206,6 +231,18 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.TokenCustomEndpoint { viper.Set(config.TokenCustomEndpointKey, "") } + if model.IntakeCustomEndpoint { + viper.Set(config.IntakeCustomEndpointKey, "") + } + if model.LogsCustomEndpoint { + viper.Set(config.LogsCustomEndpointKey, "") + } + if model.SfsCustomEndpoint { + viper.Set(config.SfsCustomEndpointKey, "") + } + if model.CDNCustomEndpoint { + viper.Set(config.CDNCustomEndpointKey, "") + } err := config.Write() if err != nil { @@ -224,6 +261,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(regionFlag, false, "Region") cmd.Flags().Bool(outputFormatFlag, false, "Output format") cmd.Flags().Bool(verbosityFlag, false, "Verbosity of the CLI") + cmd.Flags().Bool(assumeYesFlag, false, "If set, skips all confirmation prompts") cmd.Flags().Bool(sessionTimeLimitFlag, false, fmt.Sprintf("Maximum time before authentication is required again. If unset, defaults to %s", config.SessionTimeLimitDefault)) cmd.Flags().Bool(identityProviderCustomWellKnownConfigurationFlag, false, "Identity Provider well-known OpenID configuration URL. If unset, uses the default identity provider") @@ -233,6 +271,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(observabilityCustomEndpointFlag, false, "Observability API base URL. If unset, uses the default base URL") cmd.Flags().Bool(authorizationCustomEndpointFlag, false, "Authorization API base URL. If unset, uses the default base URL") cmd.Flags().Bool(dnsCustomEndpointFlag, false, "DNS API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(edgeCustomEndpointFlag, false, "Edge API base URL. If unset, uses the default base URL") cmd.Flags().Bool(loadBalancerCustomEndpointFlag, false, "Load Balancer API base URL. If unset, uses the default base URL") cmd.Flags().Bool(logMeCustomEndpointFlag, false, "LogMe API base URL. If unset, uses the default base URL") cmd.Flags().Bool(mariaDBCustomEndpointFlag, false, "MariaDB API base URL. If unset, uses the default base URL") @@ -244,6 +283,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(redisCustomEndpointFlag, false, "Redis API base URL. If unset, uses the default base URL") cmd.Flags().Bool(resourceManagerCustomEndpointFlag, false, "Resource Manager API base URL. If unset, uses the default base URL") cmd.Flags().Bool(secretsManagerCustomEndpointFlag, false, "Secrets Manager API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(kmsCustomEndpointFlag, false, "KMS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(serviceAccountCustomEndpointFlag, false, "Service Account API base URL. If unset, uses the default base URL") cmd.Flags().Bool(serviceEnablementCustomEndpointFlag, false, "Service Enablement API base URL. If unset, uses the default base URL") cmd.Flags().Bool(serverBackupCustomEndpointFlag, false, "Server Backup base URL. If unset, uses the default base URL") @@ -253,6 +293,10 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(sqlServerFlexCustomEndpointFlag, false, "SQLServer Flex API base URL. If unset, uses the default base URL") cmd.Flags().Bool(iaasCustomEndpointFlag, false, "IaaS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(tokenCustomEndpointFlag, false, "Custom token endpoint of the Service Account API, which is used to request access tokens when the service account authentication is activated. Not relevant for user authentication.") + cmd.Flags().Bool(intakeCustomEndpointFlag, false, "Intake API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(logsCustomEndpointFlag, false, "Logs API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(sfsCustomEndpointFlag, false, "SFS API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(cdnCustomEndpointFlag, false, "Custom CDN endpoint URL. If unset, uses the default base URL") } func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { @@ -262,6 +306,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { ProjectId: flags.FlagToBoolValue(p, cmd, projectIdFlag), Region: flags.FlagToBoolValue(p, cmd, regionFlag), Verbosity: flags.FlagToBoolValue(p, cmd, verbosityFlag), + AssumeYes: flags.FlagToBoolValue(p, cmd, assumeYesFlag), SessionTimeLimit: flags.FlagToBoolValue(p, cmd, sessionTimeLimitFlag), IdentityProviderCustomEndpoint: flags.FlagToBoolValue(p, cmd, identityProviderCustomWellKnownConfigurationFlag), @@ -270,6 +315,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { AuthorizationCustomEndpoint: flags.FlagToBoolValue(p, cmd, authorizationCustomEndpointFlag), DNSCustomEndpoint: flags.FlagToBoolValue(p, cmd, dnsCustomEndpointFlag), + EdgeCustomEndpoint: flags.FlagToBoolValue(p, cmd, edgeCustomEndpointFlag), LoadBalancerCustomEndpoint: flags.FlagToBoolValue(p, cmd, loadBalancerCustomEndpointFlag), LogMeCustomEndpoint: flags.FlagToBoolValue(p, cmd, logMeCustomEndpointFlag), MariaDBCustomEndpoint: flags.FlagToBoolValue(p, cmd, mariaDBCustomEndpointFlag), @@ -282,25 +328,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { RedisCustomEndpoint: flags.FlagToBoolValue(p, cmd, redisCustomEndpointFlag), ResourceManagerCustomEndpoint: flags.FlagToBoolValue(p, cmd, resourceManagerCustomEndpointFlag), SecretsManagerCustomEndpoint: flags.FlagToBoolValue(p, cmd, secretsManagerCustomEndpointFlag), + KMSCustomEndpoint: flags.FlagToBoolValue(p, cmd, kmsCustomEndpointFlag), ServiceAccountCustomEndpoint: flags.FlagToBoolValue(p, cmd, serviceAccountCustomEndpointFlag), ServiceEnablementCustomEndpoint: flags.FlagToBoolValue(p, cmd, serviceEnablementCustomEndpointFlag), ServerBackupCustomEndpoint: flags.FlagToBoolValue(p, cmd, serverBackupCustomEndpointFlag), ServerOsUpdateCustomEndpoint: flags.FlagToBoolValue(p, cmd, serverOsUpdateCustomEndpointFlag), RunCommandCustomEndpoint: flags.FlagToBoolValue(p, cmd, runCommandCustomEndpointFlag), SKECustomEndpoint: flags.FlagToBoolValue(p, cmd, skeCustomEndpointFlag), + SfsCustomEndpoint: flags.FlagToBoolValue(p, cmd, sfsCustomEndpointFlag), SQLServerFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, sqlServerFlexCustomEndpointFlag), IaaSCustomEndpoint: flags.FlagToBoolValue(p, cmd, iaasCustomEndpointFlag), TokenCustomEndpoint: flags.FlagToBoolValue(p, cmd, tokenCustomEndpointFlag), + IntakeCustomEndpoint: flags.FlagToBoolValue(p, cmd, intakeCustomEndpointFlag), + LogsCustomEndpoint: flags.FlagToBoolValue(p, cmd, logsCustomEndpointFlag), + CDNCustomEndpoint: flags.FlagToBoolValue(p, cmd, cdnCustomEndpointFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model } diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index bc262c002..49b6160f8 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -4,6 +4,8 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/google/go-cmp/cmp" @@ -15,6 +17,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool outputFormatFlag: true, projectIdFlag: true, verbosityFlag: true, + assumeYesFlag: true, sessionTimeLimitFlag: true, identityProviderCustomWellKnownConfigurationFlag: true, @@ -23,6 +26,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool authorizationCustomEndpointFlag: true, dnsCustomEndpointFlag: true, + edgeCustomEndpointFlag: true, loadBalancerCustomEndpointFlag: true, logMeCustomEndpointFlag: true, mariaDBCustomEndpointFlag: true, @@ -33,14 +37,19 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool redisCustomEndpointFlag: true, resourceManagerCustomEndpointFlag: true, secretsManagerCustomEndpointFlag: true, + kmsCustomEndpointFlag: true, serviceAccountCustomEndpointFlag: true, serverBackupCustomEndpointFlag: true, serverOsUpdateCustomEndpointFlag: true, runCommandCustomEndpointFlag: true, + sfsCustomEndpointFlag: true, skeCustomEndpointFlag: true, sqlServerFlexCustomEndpointFlag: true, iaasCustomEndpointFlag: true, tokenCustomEndpointFlag: true, + intakeCustomEndpointFlag: true, + logsCustomEndpointFlag: true, + cdnCustomEndpointFlag: true, } for _, mod := range mods { mod(flagValues) @@ -54,6 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { OutputFormat: true, ProjectId: true, Verbosity: true, + AssumeYes: true, SessionTimeLimit: true, IdentityProviderCustomEndpoint: true, @@ -62,6 +72,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { AuthorizationCustomEndpoint: true, DNSCustomEndpoint: true, + EdgeCustomEndpoint: true, LoadBalancerCustomEndpoint: true, LogMeCustomEndpoint: true, MariaDBCustomEndpoint: true, @@ -72,14 +83,19 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { RedisCustomEndpoint: true, ResourceManagerCustomEndpoint: true, SecretsManagerCustomEndpoint: true, + KMSCustomEndpoint: true, ServiceAccountCustomEndpoint: true, ServerBackupCustomEndpoint: true, ServerOsUpdateCustomEndpoint: true, RunCommandCustomEndpoint: true, + SfsCustomEndpoint: true, SKECustomEndpoint: true, SQLServerFlexCustomEndpoint: true, IaaSCustomEndpoint: true, TokenCustomEndpoint: true, + IntakeCustomEndpoint: true, + LogsCustomEndpoint: true, + CDNCustomEndpoint: true, } for _, mod := range mods { mod(model) @@ -109,6 +125,7 @@ func TestParseInput(t *testing.T) { model.OutputFormat = false model.ProjectId = false model.Verbosity = false + model.AssumeYes = false model.SessionTimeLimit = false model.IdentityProviderCustomEndpoint = false @@ -117,6 +134,7 @@ func TestParseInput(t *testing.T) { model.AuthorizationCustomEndpoint = false model.DNSCustomEndpoint = false + model.EdgeCustomEndpoint = false model.LoadBalancerCustomEndpoint = false model.LogMeCustomEndpoint = false model.MariaDBCustomEndpoint = false @@ -127,14 +145,19 @@ func TestParseInput(t *testing.T) { model.RedisCustomEndpoint = false model.ResourceManagerCustomEndpoint = false model.SecretsManagerCustomEndpoint = false + model.KMSCustomEndpoint = false model.ServiceAccountCustomEndpoint = false model.ServerBackupCustomEndpoint = false model.ServerOsUpdateCustomEndpoint = false model.RunCommandCustomEndpoint = false + model.SfsCustomEndpoint = false model.SKECustomEndpoint = false model.SQLServerFlexCustomEndpoint = false model.IaaSCustomEndpoint = false model.TokenCustomEndpoint = false + model.IntakeCustomEndpoint = false + model.LogsCustomEndpoint = false + model.CDNCustomEndpoint = false }), }, { @@ -207,6 +230,16 @@ func TestParseInput(t *testing.T) { model.DNSCustomEndpoint = false }), }, + { + description: "edge custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[edgeCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.EdgeCustomEndpoint = false + }), + }, { description: "secrets manager custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { @@ -217,6 +250,16 @@ func TestParseInput(t *testing.T) { model.SecretsManagerCustomEndpoint = false }), }, + { + description: "kms custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[kmsCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.KMSCustomEndpoint = false + }), + }, { description: "service account custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { @@ -227,6 +270,16 @@ func TestParseInput(t *testing.T) { model.ServiceAccountCustomEndpoint = false }), }, + { + description: "sfs custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[sfsCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SfsCustomEndpoint = false + }), + }, { description: "ske custom endpoint empty", flagValues: fixtureFlagValues(func(flagValues map[string]bool) { @@ -287,11 +340,31 @@ func TestParseInput(t *testing.T) { model.TokenCustomEndpoint = false }), }, + { + description: "logs custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[logsCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LogsCustomEndpoint = false + }), + }, + { + description: "cdn custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[cdnCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.CDNCustomEndpoint = false + }), + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) for flag, value := range tt.flagValues { stringBool := fmt.Sprintf("%v", value) diff --git a/internal/cmd/curl/curl.go b/internal/cmd/curl/curl.go index 9959e50f1..341654dfd 100644 --- a/internal/cmd/curl/curl.go +++ b/internal/cmd/curl/curl.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -45,7 +47,7 @@ type inputModel struct { OutputFile *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("curl %s", urlArg), Short: "Executes an authenticated HTTP request to an endpoint", @@ -70,12 +72,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), Args: args.SingleArg(urlArg, utils.ValidateURLDomain), RunE: func(cmd *cobra.Command, args []string) (err error) { - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } - bearerToken, err := getBearerToken(p) + bearerToken, err := getBearerToken(params.Printer) if err != nil { return err } @@ -99,7 +101,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } }() - err = outputResponse(p, model, resp) + err = outputResponse(params.Printer, model, resp) if err != nil { return err } @@ -153,15 +155,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu OutputFile: flags.FlagToStringPointer(p, cmd, outputFileFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -180,17 +174,10 @@ func getBearerToken(p *print.Printer) (string, error) { return "", &errors.SessionExpiredError{} } - accessToken, err := auth.GetAccessToken() - if err != nil { - return "", err - } - - accessTokenExpired, err := auth.TokenExpired(accessToken) + accessToken, err := auth.GetValidAccessToken(p) if err != nil { - return "", err - } - if accessTokenExpired { - return "", &errors.AccessTokenExpiredError{} + p.Debug(print.ErrorLevel, "get valid access token: %v", err) + return "", &errors.SessionExpiredError{} } return accessToken, nil diff --git a/internal/cmd/curl/curl_test.go b/internal/cmd/curl/curl_test.go index c7b4dcf4b..279e52153 100644 --- a/internal/cmd/curl/curl_test.go +++ b/internal/cmd/curl/curl_test.go @@ -10,9 +10,12 @@ import ( "strings" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -216,7 +219,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -447,7 +450,7 @@ func TestOutputResponse(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResponse(p, tt.args.model, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/dns/dns.go b/internal/cmd/dns/dns.go index a89e959c5..216c02dac 100644 --- a/internal/cmd/dns/dns.go +++ b/internal/cmd/dns/dns.go @@ -4,13 +4,13 @@ import ( recordset "github.com/stackitcloud/stackit-cli/internal/cmd/dns/record-set" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "dns", Short: "Provides functionality for DNS", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(zone.NewCmd(p)) - cmd.AddCommand(recordset.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(zone.NewCmd(params)) + cmd.AddCommand(recordset.NewCmd(params)) } diff --git a/internal/cmd/dns/record-set/create/create.go b/internal/cmd/dns/record-set/create/create.go index 65ea58911..1cf8c448b 100644 --- a/internal/cmd/dns/record-set/create/create.go +++ b/internal/cmd/dns/record-set/create/create.go @@ -2,11 +2,15 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,8 +21,6 @@ import ( dnsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/dns" - "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" ) const ( @@ -29,8 +31,8 @@ const ( ttlFlag = "ttl" typeFlag = "type" - defaultType = "A" - txtType = "TXT" + defaultType = dns.CREATERECORDSETPAYLOADTYPE_A + txtType = dns.CREATERECORDSETPAYLOADTYPE_TXT ) type inputModel struct { @@ -40,10 +42,10 @@ type inputModel struct { Name *string Records []string TTL *int64 - Type string + Type dns.CreateRecordSetPayloadTypes } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a DNS record set", @@ -54,31 +56,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) if err != nil { - p.Debug(print.ErrorLevel, "get zone name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) zoneLabel = model.ZoneId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a record set for zone %s?", zoneLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a record set for zone %s?", zoneLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -91,16 +91,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating record set") - _, err = wait.CreateRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, recordSetId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating record set", func() error { + _, err = wait.CreateRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, recordSetId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for DNS record set creation: %w", err) } - s.Stop() } - return outputResult(p, model, zoneLabel, resp) + return outputResult(params.Printer, model, zoneLabel, resp) }, } configureFlags(cmd) @@ -108,25 +108,30 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - typeFlagOptions := []string{"A", "AAAA", "SOA", "CNAME", "NS", "MX", "TXT", "SRV", "PTR", "ALIAS", "DNAME", "CAA"} + var typeFlagOptions []string + for _, val := range dns.AllowedCreateRecordSetPayloadTypesEnumValues { + typeFlagOptions = append(typeFlagOptions, string(val)) + } 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)) + cmd.Flags().Var(flags.EnumFlag(false, string(defaultType), typeFlagOptions...), typeFlag, fmt.Sprintf("Record type, one of %q", typeFlagOptions)) err := flags.MarkFlagsRequired(cmd, zoneIdFlag, nameFlag, recordFlag) cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} } + recordType := flags.FlagWithDefaultToStringValue(p, cmd, typeFlag) + model := inputModel{ GlobalFlagModel: globalFlags, ZoneId: flags.FlagToStringValue(p, cmd, zoneIdFlag), @@ -134,7 +139,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Name: flags.FlagToStringPointer(p, cmd, nameFlag), Records: flags.FlagToStringSliceValue(p, cmd, recordFlag), TTL: flags.FlagToInt64Pointer(p, cmd, ttlFlag), - Type: flags.FlagWithDefaultToStringValue(p, cmd, typeFlag), + Type: dns.CreateRecordSetPayloadTypes(recordType), } if model.Type == txtType { @@ -151,15 +156,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { } } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -184,29 +181,12 @@ func outputResult(p *print.Printer, model *inputModel, zoneLabel string, resp *d if resp == nil { return fmt.Errorf("record set response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal DNS record-set: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal DNS record-set: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Created" if model.Async { operationState = "Triggered creation of" } p.Outputf("%s record set for zone %s. Record set ID: %s\n", operationState, zoneLabel, utils.PtrString(resp.Rrset.Id)) return nil - } + }) } diff --git a/internal/cmd/dns/record-set/create/create_test.go b/internal/cmd/dns/record-set/create/create_test.go index eed9f9424..43186e1f5 100644 --- a/internal/cmd/dns/record-set/create/create_test.go +++ b/internal/cmd/dns/record-set/create/create_test.go @@ -6,17 +6,19 @@ import ( "strings" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/dns" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -32,13 +34,13 @@ var recordTxtOver255Char = []string{ 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 + globalflags.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) @@ -74,7 +76,7 @@ func fixtureRequest(mods ...func(request *dns.ApiCreateRecordSetRequest)) dns.Ap {Content: utils.Ptr("1.1.1.1")}, }, Ttl: utils.Ptr(int64(3600)), - Type: utils.Ptr("SOA"), + Type: dns.CREATERECORDSETPAYLOADTYPE_SOA.Ptr(), }) for _, mod := range mods { mod(&request) @@ -85,6 +87,7 @@ func fixtureRequest(mods ...func(request *dns.ApiCreateRecordSetRequest)) dns.Ap func TestParseInput(t *testing.T) { var tests = []struct { description string + argValues []string flagValues map[string]string recordFlagValues []string isValid bool @@ -104,10 +107,10 @@ func TestParseInput(t *testing.T) { { description: "required fields only", flagValues: map[string]string{ - projectIdFlag: testProjectId, - zoneIdFlag: testZoneId, - nameFlag: "example.com", - recordFlag: "1.1.1.1", + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + nameFlag: "example.com", + recordFlag: "1.1.1.1", }, isValid: true, expectedModel: &inputModel{ @@ -124,12 +127,12 @@ func TestParseInput(t *testing.T) { { description: "zero values", flagValues: map[string]string{ - projectIdFlag: testProjectId, - zoneIdFlag: testZoneId, - commentFlag: "", - nameFlag: "", - recordFlag: "1.1.1.1", - ttlFlag: "0", + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + commentFlag: "", + nameFlag: "", + recordFlag: "1.1.1.1", + ttlFlag: "0", }, isValid: true, expectedModel: &inputModel{ @@ -148,21 +151,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -246,7 +249,7 @@ func TestParseInput(t *testing.T) { { description: "TXT record with > 255 characters", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[typeFlag] = txtType + flagValues[typeFlag] = string(txtType) flagValues[recordFlag] = strings.Join(recordTxtOver255Char, "") }), isValid: true, @@ -266,56 +269,9 @@ func TestParseInput(t *testing.T) { } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + recordFlag: tt.recordFlagValues, + }, tt.isValid) }) } } @@ -395,7 +351,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.zoneLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/dns/record-set/delete/delete.go b/internal/cmd/dns/record-set/delete/delete.go index 4bb3a1c88..ff1478ccc 100644 --- a/internal/cmd/dns/record-set/delete/delete.go +++ b/internal/cmd/dns/record-set/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +34,7 @@ type inputModel struct { RecordSetId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", recordSetIdArg), Short: "Deletes a DNS record set", @@ -45,35 +47,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) if err != nil { - p.Debug(print.ErrorLevel, "get zone name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) zoneLabel = model.ZoneId } recordSetLabel, err := dnsUtils.GetRecordSetName(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId) if err != nil { - p.Debug(print.ErrorLevel, "get record set name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get record set name: %v", err) 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 = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete record set %s of zone %s? (This cannot be undone)", recordSetLabel, zoneLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -88,20 +88,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting record set") - _, err = wait.DeleteRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting record set", func() error { + _, err = wait.DeleteRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId).WaitWithContext(ctx) + return err + }) 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" } - p.Info("%s record set %s of zone %s\n", operationState, recordSetLabel, zoneLabel) + params.Printer.Info("%s record set %s of zone %s\n", operationState, recordSetLabel, zoneLabel) return nil }, } @@ -130,15 +130,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu RecordSetId: recordSetId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/dns/record-set/delete/delete_test.go b/internal/cmd/dns/record-set/delete/delete_test.go index 137c85267..46a5c6d28 100644 --- a/internal/cmd/dns/record-set/delete/delete_test.go +++ b/internal/cmd/dns/record-set/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,8 +13,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/dns" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,8 +33,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - zoneIdFlag: testZoneId, + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, } for _, mod := range mods { mod(flagValues) @@ -104,7 +102,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +110,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +118,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -164,54 +162,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/dns/record-set/describe/describe.go b/internal/cmd/dns/record-set/describe/describe.go index 03a757a7a..dabe1d5c1 100644 --- a/internal/cmd/dns/record-set/describe/describe.go +++ b/internal/cmd/dns/record-set/describe/describe.go @@ -2,11 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +33,7 @@ type inputModel struct { RecordSetId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", recordSetIdArg), Short: "Shows details of a DNS record set", @@ -49,13 +49,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,7 +68,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } recordSet := resp.Rrset - return outputResult(p, model.OutputFormat, recordSet) + return outputResult(params.Printer, model.OutputFormat, recordSet) }, } configureFlags(cmd) @@ -96,15 +96,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu RecordSetId: recordSetId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -117,24 +109,8 @@ func outputResult(p *print.Printer, outputFormat string, recordSet *dns.RecordSe if recordSet == nil { return fmt.Errorf("record set response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(recordSet, "", " ") - if err != nil { - return fmt.Errorf("marshal DNS record set: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(recordSet, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal DNS record set: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, recordSet, func() error { recordsData := make([]string, 0, len(*recordSet.Records)) for _, r := range *recordSet.Records { recordsData = append(recordsData, *r.Content) @@ -159,5 +135,5 @@ func outputResult(p *print.Printer, outputFormat string, recordSet *dns.RecordSe } return nil - } + }) } diff --git a/internal/cmd/dns/record-set/describe/describe_test.go b/internal/cmd/dns/record-set/describe/describe_test.go index a2cbc48e7..41d38ff69 100644 --- a/internal/cmd/dns/record-set/describe/describe_test.go +++ b/internal/cmd/dns/record-set/describe/describe_test.go @@ -4,16 +4,18 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -34,8 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - zoneIdFlag: testZoneId, + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, } for _, mod := range mods { mod(flagValues) @@ -103,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -111,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -119,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -163,54 +165,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -269,7 +224,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.recordSet); (err != nil) != tt.wantErr { diff --git a/internal/cmd/dns/record-set/list/list.go b/internal/cmd/dns/record-set/list/list.go index 2062a7c84..dfd02612c 100644 --- a/internal/cmd/dns/record-set/list/list.go +++ b/internal/cmd/dns/record-set/list/list.go @@ -2,13 +2,15 @@ package list import ( "context" - "encoding/json" "fmt" "math" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -19,7 +21,6 @@ import ( dnsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/dns" ) const ( @@ -50,7 +51,7 @@ type inputModel struct { PageSize int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists DNS record sets", @@ -73,15 +74,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -91,16 +92,14 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return err } - if len(recordSets) == 0 { - zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) - if err != nil { - p.Debug(print.ErrorLevel, "get zone name: %v", err) - zoneLabel = model.ZoneId - } - p.Info("No record sets found for zone %s matching the criteria\n", zoneLabel) - return nil + + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) + zoneLabel = model.ZoneId } - return outputResult(p, model.OutputFormat, recordSets) + + return outputResult(params.Printer, model.OutputFormat, zoneLabel, recordSets) }, } @@ -124,7 +123,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -164,15 +163,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { PageSize: pageSize, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -248,25 +239,13 @@ func fetchRecordSets(ctx context.Context, model *inputModel, apiClient dnsClient return recordSets, nil } -func outputResult(p *print.Printer, outputFormat string, recordSets []dns.RecordSet) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(recordSets, "", " ") - if err != nil { - return fmt.Errorf("marshal DNS record set list: %w", err) +func outputResult(p *print.Printer, outputFormat, zoneLabel string, recordSets []dns.RecordSet) error { + return p.OutputResult(outputFormat, recordSets, func() error { + if len(recordSets) == 0 { + p.Outputf("No record sets found for zone %s matching the criteria\n", zoneLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(recordSets, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal DNS record set list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "STATUS", "TTL", "TYPE", "RECORD DATA") for i := range recordSets { @@ -291,5 +270,5 @@ func outputResult(p *print.Printer, outputFormat string, recordSets []dns.Record } return nil - } + }) } diff --git a/internal/cmd/dns/record-set/list/list_test.go b/internal/cmd/dns/record-set/list/list_test.go index d41e1f304..a4751a5b8 100644 --- a/internal/cmd/dns/record-set/list/list_test.go +++ b/internal/cmd/dns/record-set/list/list_test.go @@ -8,18 +8,20 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - 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") @@ -29,10 +31,10 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + nameLikeFlag: "some-pattern", + orderByNameFlag: "asc", } for _, mod := range mods { mod(flagValues) @@ -71,6 +73,7 @@ func fixtureRequest(mods ...func(request *dns.ApiListRecordSetsRequest)) dns.Api func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -127,8 +130,8 @@ func TestParseInput(t *testing.T) { { description: "required fields only", flagValues: map[string]string{ - projectIdFlag: testProjectId, - zoneIdFlag: testZoneId, + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, }, isValid: true, expectedModel: &inputModel{ @@ -143,21 +146,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -213,46 +216,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -504,6 +468,7 @@ func TestFetchRecordSets(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + zoneLabel string recordSets []dns.RecordSet } tests := []struct { @@ -518,10 +483,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.recordSets); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.zoneLabel, tt.args.recordSets); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/dns/record-set/record_set.go b/internal/cmd/dns/record-set/record_set.go index 698750f4b..548c66ee6 100644 --- a/internal/cmd/dns/record-set/record_set.go +++ b/internal/cmd/dns/record-set/record_set.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/dns/record-set/list" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/record-set/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "record-set", Short: "Provides functionality for DNS record set", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/dns/record-set/update/update.go b/internal/cmd/dns/record-set/update/update.go index 2b3d258aa..6246ff58f 100644 --- a/internal/cmd/dns/record-set/update/update.go +++ b/internal/cmd/dns/record-set/update/update.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -42,7 +44,7 @@ type inputModel struct { Type *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", recordSetIdArg), Short: "Updates a DNS record set", @@ -55,32 +57,32 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) if err != nil { - p.Debug(print.ErrorLevel, "get zone name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) zoneLabel = model.ZoneId } recordSetLabel, err := dnsUtils.GetRecordSetName(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId) if err != nil { - p.Debug(print.ErrorLevel, "get record set name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get record set name: %v", err) recordSetLabel = model.RecordSetId } typeLabel, err := dnsUtils.GetRecordSetType(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId) if err != nil { - p.Debug(print.ErrorLevel, "get record set type: %v", err) + params.Printer.Debug(print.ErrorLevel, "get record set type: %v", err) } model.Type = typeLabel @@ -91,12 +93,10 @@ func NewCmd(p *print.Printer) *cobra.Command { } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update record set %s of zone %s?", recordSetLabel, zoneLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update record set %s of zone %s?", recordSetLabel, zoneLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -108,20 +108,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating record set") - _, err = wait.PartialUpdateRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating record set", func() error { + _, err = wait.PartialUpdateRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId).WaitWithContext(ctx) + return err + }) 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" } - p.Info("%s record set %s of zone %s\n", operationState, recordSetLabel, zoneLabel) + params.Printer.Info("%s record set %s of zone %s\n", operationState, recordSetLabel, zoneLabel) return nil }, } @@ -168,15 +168,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu TTL: ttl, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/dns/record-set/update/update_test.go b/internal/cmd/dns/record-set/update/update_test.go index 9b07ffabb..a85c99a12 100644 --- a/internal/cmd/dns/record-set/update/update_test.go +++ b/internal/cmd/dns/record-set/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,8 +16,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/dns" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -43,12 +43,12 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { 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", + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + commentFlag: "comment", + nameFlag: "example.com", + recordFlag: "1.1.1.1", + ttlFlag: "3600", } for _, mod := range mods { mod(flagValues) @@ -130,8 +130,8 @@ func TestParseInput(t *testing.T) { description: "required flags only (no values to update)", argValues: fixtureArgValues(), flagValues: map[string]string{ - projectIdFlag: testProjectId, - zoneIdFlag: testZoneId, + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, }, isValid: false, expectedModel: &inputModel{ @@ -147,12 +147,12 @@ func TestParseInput(t *testing.T) { description: "zero values", argValues: fixtureArgValues(), flagValues: map[string]string{ - projectIdFlag: testProjectId, - zoneIdFlag: testZoneId, - commentFlag: "", - nameFlag: "", - recordFlag: "1.1.1.1", - ttlFlag: "0", + globalflags.ProjectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + commentFlag: "", + nameFlag: "", + recordFlag: "1.1.1.1", + ttlFlag: "0", }, isValid: true, expectedModel: &inputModel{ @@ -172,7 +172,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -180,7 +180,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -188,7 +188,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -253,7 +253,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/dns/zone/clone/clone.go b/internal/cmd/dns/zone/clone/clone.go index a7a4e42a0..de171a2be 100644 --- a/internal/cmd/dns/zone/clone/clone.go +++ b/internal/cmd/dns/zone/clone/clone.go @@ -2,10 +2,10 @@ package clone import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -39,7 +39,7 @@ type inputModel struct { ZoneId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("clone %s", zoneIdArg), Short: "Clones a DNS zone", @@ -58,29 +58,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) if err != nil { - p.Debug(print.ErrorLevel, "get zone name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) zoneLabel = model.ZoneId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to clone the zone %q?", zoneLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to clone the zone %q?", zoneLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -93,16 +91,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Cloning zone") - _, err = wait.CreateZoneWaitHandler(ctx, apiClient, model.ProjectId, zoneId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Cloning zone", func() error { + _, err = wait.CreateZoneWaitHandler(ctx, apiClient, model.ProjectId, zoneId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for DNS zone cloning: %w", err) } - s.Stop() } - return outputResult(p, model, zoneLabel, resp) + return outputResult(params.Printer, model, zoneLabel, resp) }, } configureFlags(cmd) @@ -136,15 +134,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ZoneId: zoneId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -163,29 +153,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp if resp == nil { return fmt.Errorf("dns zone response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal DNS zone: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal DNS zone: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Cloned" if model.Async { operationState = "Triggered cloning of" } p.Outputf("%s zone for project %q. Zone ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Zone.Id)) return nil - } + }) } diff --git a/internal/cmd/dns/zone/clone/clone_test.go b/internal/cmd/dns/zone/clone/clone_test.go index ea4f0621b..84154b80f 100644 --- a/internal/cmd/dns/zone/clone/clone_test.go +++ b/internal/cmd/dns/zone/clone/clone_test.go @@ -4,17 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/dns" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -34,11 +36,11 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - nameFlag: "example", - dnsNameFlag: "example.com", - descriptionFlag: "Example", - adjustRecordsFlag: "false", + globalflags.ProjectIdFlag: testProjectId, + nameFlag: "example", + dnsNameFlag: "example.com", + descriptionFlag: "Example", + adjustRecordsFlag: "false", } for _, mod := range mods { mod(flagValues) @@ -115,8 +117,8 @@ func TestParseInput(t *testing.T) { description: "required fields only", argValues: []string{testZoneId}, flagValues: map[string]string{ - projectIdFlag: testProjectId, - dnsNameFlag: "example.com", + globalflags.ProjectIdFlag: testProjectId, + dnsNameFlag: "example.com", }, isValid: true, expectedModel: &inputModel{ @@ -132,7 +134,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -140,7 +142,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -148,7 +150,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -168,54 +170,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -291,7 +246,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/dns/zone/create/create.go b/internal/cmd/dns/zone/create/create.go index fadfbd183..5b68f97b8 100644 --- a/internal/cmd/dns/zone/create/create.go +++ b/internal/cmd/dns/zone/create/create.go @@ -2,10 +2,10 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -45,7 +45,7 @@ type inputModel struct { DefaultTTL *int64 Primaries *[]string Acl *string - Type *string + Type *dns.CreateZonePayloadTypes RetryTime *int64 RefreshTime *int64 NegativeCache *int64 @@ -55,7 +55,7 @@ type inputModel struct { ContactEmail *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a DNS zone", @@ -69,31 +69,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a zone for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a zone for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -106,16 +104,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating zone") - _, err = wait.CreateZoneWaitHandler(ctx, apiClient, model.ProjectId, zoneId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating zone", func() error { + _, err = wait.CreateZoneWaitHandler(ctx, apiClient, model.ProjectId, zoneId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for DNS zone creation: %w", err) } - s.Stop() } - return outputResult(p, model, projectLabel, resp) + return outputResult(params.Printer, model, projectLabel, resp) }, } configureFlags(cmd) @@ -123,12 +121,17 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { + var typeFlagOptions []string + for _, val := range dns.AllowedCreateZonePayloadTypesEnumValues { + typeFlagOptions = append(typeFlagOptions, string(val)) + } + 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().Var(flags.EnumFlag(false, "", append(typeFlagOptions, "")...), typeFlag, fmt.Sprintf("Zone type, one of: %q", typeFlagOptions)) cmd.Flags().Int64(retryTimeFlag, 0, "Retry time") cmd.Flags().Int64(refreshTimeFlag, 0, "Refresh time") cmd.Flags().Int64(negativeCacheFlag, 0, "Negative cache") @@ -141,12 +144,17 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} } + var zoneType *dns.CreateZonePayloadTypes + if zoneTypeString := flags.FlagToStringPointer(p, cmd, typeFlag); zoneTypeString != nil && *zoneTypeString != "" { + zoneType = dns.CreateZonePayloadTypes(*zoneTypeString).Ptr() + } + model := inputModel{ GlobalFlagModel: globalFlags, Name: flags.FlagToStringPointer(p, cmd, nameFlag), @@ -154,7 +162,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { DefaultTTL: flags.FlagToInt64Pointer(p, cmd, defaultTTLFlag), Primaries: flags.FlagToStringSlicePointer(p, cmd, primaryFlag), Acl: flags.FlagToStringPointer(p, cmd, aclFlag), - Type: flags.FlagToStringPointer(p, cmd, typeFlag), + Type: zoneType, RetryTime: flags.FlagToInt64Pointer(p, cmd, retryTimeFlag), RefreshTime: flags.FlagToInt64Pointer(p, cmd, refreshTimeFlag), NegativeCache: flags.FlagToInt64Pointer(p, cmd, negativeCacheFlag), @@ -164,15 +172,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ContactEmail: flags.FlagToStringPointer(p, cmd, contactEmailFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -200,29 +200,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp if resp == nil { return fmt.Errorf("dns zone response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal DNS zone: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal DNS zone: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Created" if model.Async { operationState = "Triggered creation of" } p.Outputf("%s zone for project %q. Zone ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Zone.Id)) return nil - } + }) } diff --git a/internal/cmd/dns/zone/create/create_test.go b/internal/cmd/dns/zone/create/create_test.go index 5bcc8728a..f4fd21ec6 100644 --- a/internal/cmd/dns/zone/create/create_test.go +++ b/internal/cmd/dns/zone/create/create_test.go @@ -4,17 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/dns" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -23,20 +25,20 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + nameFlag: "example", + dnsNameFlag: "example.com", + defaultTTLFlag: "3600", + aclFlag: "0.0.0.0/0", + typeFlag: string(dns.CREATEZONEPAYLOADTYPE_PRIMARY), + 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) @@ -55,7 +57,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { 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"), + Type: dns.CREATEZONEPAYLOADTYPE_PRIMARY.Ptr(), RetryTime: utils.Ptr(int64(600)), RefreshTime: utils.Ptr(int64(3600)), NegativeCache: utils.Ptr(int64(60)), @@ -78,7 +80,7 @@ func fixtureRequest(mods ...func(request *dns.ApiCreateZoneRequest)) dns.ApiCrea 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"), + Type: dns.CREATEZONEPAYLOADTYPE_PRIMARY.Ptr(), RetryTime: utils.Ptr(int64(600)), RefreshTime: utils.Ptr(int64(3600)), NegativeCache: utils.Ptr(int64(60)), @@ -96,6 +98,7 @@ func fixtureRequest(mods ...func(request *dns.ApiCreateZoneRequest)) dns.ApiCrea func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string primaryFlagValues []string isValid bool @@ -115,9 +118,9 @@ func TestParseInput(t *testing.T) { { description: "required fields only", flagValues: map[string]string{ - projectIdFlag: testProjectId, - nameFlag: "example", - dnsNameFlag: "example.com", + globalflags.ProjectIdFlag: testProjectId, + nameFlag: "example", + dnsNameFlag: "example.com", }, isValid: true, expectedModel: &inputModel{ @@ -132,20 +135,19 @@ func TestParseInput(t *testing.T) { { 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: "", + globalflags.ProjectIdFlag: testProjectId, + nameFlag: "", + dnsNameFlag: "", + defaultTTLFlag: "0", + aclFlag: "", + typeFlag: "", + retryTimeFlag: "0", + refreshTimeFlag: "0", + negativeCacheFlag: "0", + isReverseZoneFlag: "false", + expireTimeFlag: "0", + descriptionFlag: "", + contactEmailFlag: "", }, isValid: true, expectedModel: &inputModel{ @@ -156,9 +158,9 @@ func TestParseInput(t *testing.T) { Name: utils.Ptr(""), DnsName: utils.Ptr(""), DefaultTTL: utils.Ptr(int64(0)), - Primaries: utils.Ptr([]string{}), + Primaries: nil, Acl: utils.Ptr(""), - Type: utils.Ptr(""), + Type: nil, RetryTime: utils.Ptr(int64(0)), RefreshTime: utils.Ptr(int64(0)), NegativeCache: utils.Ptr(int64(0)), @@ -171,21 +173,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -215,56 +217,9 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + primaryFlag: tt.primaryFlagValues, + }, tt.isValid) }) } } @@ -339,7 +294,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/dns/zone/delete/delete.go b/internal/cmd/dns/zone/delete/delete.go index 2ad4373dd..9945054ee 100644 --- a/internal/cmd/dns/zone/delete/delete.go +++ b/internal/cmd/dns/zone/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { ZoneId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", zoneIdArg), Short: "Deletes a DNS zone", @@ -41,28 +43,26 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) if err != nil { - p.Debug(print.ErrorLevel, "get zone name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) zoneLabel = model.ZoneId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete zone %q? (This cannot be undone)", zoneLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete zone %q? (This cannot be undone)", zoneLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -77,20 +77,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting zone") - _, err = wait.DeleteZoneWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting zone", func() error { + _, err = wait.DeleteZoneWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for DNS zone deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s zone %s\n", operationState, zoneLabel) + params.Printer.Info("%s zone %s\n", operationState, zoneLabel) return nil }, } @@ -109,15 +109,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ZoneId: zoneId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/dns/zone/delete/delete_test.go b/internal/cmd/dns/zone/delete/delete_test.go index 38c534910..32eabe63b 100644 --- a/internal/cmd/dns/zone/delete/delete_test.go +++ b/internal/cmd/dns/zone/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,8 +13,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/dns" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -34,7 +32,7 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, } for _, mod := range mods { mod(flagValues) @@ -101,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +115,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/dns/zone/describe/describe.go b/internal/cmd/dns/zone/describe/describe.go index bd6ada66d..51d2fcc8e 100644 --- a/internal/cmd/dns/zone/describe/describe.go +++ b/internal/cmd/dns/zone/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +28,7 @@ type inputModel struct { ZoneId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", zoneIdArg), Short: "Shows details of a DNS zone", @@ -44,12 +44,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -62,7 +62,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } zone := resp.Zone - return outputResult(p, model.OutputFormat, zone) + return outputResult(params.Printer, model.OutputFormat, zone) }, } return cmd @@ -81,15 +81,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ZoneId: zoneId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -102,24 +94,8 @@ func outputResult(p *print.Printer, outputFormat string, zone *dns.Zone) error { if zone == nil { return fmt.Errorf("zone response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(zone, "", " ") - if err != nil { - return fmt.Errorf("marshal DNS zone: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(zone, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal DNS zone: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, zone, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(zone.Id)) table.AddSeparator() @@ -156,5 +132,5 @@ func outputResult(p *print.Printer, outputFormat string, zone *dns.Zone) error { } return nil - } + }) } diff --git a/internal/cmd/dns/zone/describe/describe_test.go b/internal/cmd/dns/zone/describe/describe_test.go index e97810889..4a3340b47 100644 --- a/internal/cmd/dns/zone/describe/describe_test.go +++ b/internal/cmd/dns/zone/describe/describe_test.go @@ -4,16 +4,18 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -33,7 +35,7 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, } for _, mod := range mods { mod(flagValues) @@ -100,7 +102,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -108,7 +110,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -116,7 +118,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,54 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -242,7 +197,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.zone); (err != nil) != tt.wantErr { diff --git a/internal/cmd/dns/zone/list/list.go b/internal/cmd/dns/zone/list/list.go index 73239b2c3..1a2640fe4 100644 --- a/internal/cmd/dns/zone/list/list.go +++ b/internal/cmd/dns/zone/list/list.go @@ -2,12 +2,12 @@ package list import ( "context" - "encoding/json" "fmt" "math" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -49,7 +49,7 @@ type inputModel struct { PageSize int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists DNS zones", @@ -69,15 +69,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List DNS zones, including deleted`, "$ stackit dns zone list --include-deleted"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -87,17 +87,14 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return err } - if len(zones) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No zones found for project %q matching the criteria\n", projectLabel) - return nil + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } - return outputResult(p, model.OutputFormat, zones) + return outputResult(params.Printer, model.OutputFormat, projectLabel, zones) }, } configureFlags(cmd) @@ -116,7 +113,7 @@ func configureFlags(cmd *cobra.Command) { 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(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -155,15 +152,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { PageSize: pageSize, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -237,26 +226,13 @@ func fetchZones(ctx context.Context, model *inputModel, apiClient dnsClient) ([] return zones, nil } -func outputResult(p *print.Printer, outputFormat string, zones []dns.Zone) error { - switch outputFormat { - case print.JSONOutputFormat: - // Show details - details, err := json.MarshalIndent(zones, "", " ") - if err != nil { - return fmt.Errorf("marshal DNS zone list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, zones []dns.Zone) error { + return p.OutputResult(outputFormat, zones, func() error { + if len(zones) == 0 { + p.Outputf("No zones found for project %q matching the criteria\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(zones, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal DNS zone list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "STATE", "TYPE", "DNS NAME", "RECORD COUNT") for i := range zones { @@ -275,5 +251,5 @@ func outputResult(p *print.Printer, outputFormat string, zones []dns.Zone) error } return nil - } + }) } diff --git a/internal/cmd/dns/zone/list/list_test.go b/internal/cmd/dns/zone/list/list_test.go index d96b980d7..a29d86714 100644 --- a/internal/cmd/dns/zone/list/list_test.go +++ b/internal/cmd/dns/zone/list/list_test.go @@ -8,18 +8,20 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - 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") @@ -28,9 +30,9 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + nameLikeFlag: "some-pattern", + orderByNameFlag: "asc", } for _, mod := range mods { mod(flagValues) @@ -68,6 +70,7 @@ func fixtureRequest(mods ...func(request *dns.ApiListZonesRequest)) dns.ApiListZ func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -124,7 +127,7 @@ func TestParseInput(t *testing.T) { { description: "required fields only", flagValues: map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, }, isValid: true, expectedModel: &inputModel{ @@ -138,21 +141,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -208,46 +211,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -498,6 +462,7 @@ func TestFetchZones(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string zones []dns.Zone } tests := []struct { @@ -512,10 +477,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.zones); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.zones); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/dns/zone/update/update.go b/internal/cmd/dns/zone/update/update.go index 0e0a6b628..c44ba724b 100644 --- a/internal/cmd/dns/zone/update/update.go +++ b/internal/cmd/dns/zone/update/update.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -50,7 +52,7 @@ type inputModel struct { ContactEmail *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", zoneIdArg), Short: "Updates a DNS zone", @@ -63,29 +65,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) if err != nil { - p.Debug(print.ErrorLevel, "get zone name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get zone name: %v", err) zoneLabel = model.ZoneId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update zone %s?", zoneLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update zone %s?", zoneLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -100,20 +100,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating zone") - _, err = wait.PartialUpdateZoneWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating zone", func() error { + _, err = wait.PartialUpdateZoneWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for DNS zone update: %w", err) } - s.Stop() } operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Info("%s zone %s\n", operationState, zoneLabel) + params.Printer.Info("%s zone %s\n", operationState, zoneLabel) return nil }, } @@ -175,15 +175,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ContactEmail: contactEmail, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/dns/zone/update/update_test.go b/internal/cmd/dns/zone/update/update_test.go index 82222ad5d..b537dc2f6 100644 --- a/internal/cmd/dns/zone/update/update_test.go +++ b/internal/cmd/dns/zone/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,8 +16,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/dns" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,17 +35,17 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { 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", + globalflags.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) @@ -135,7 +135,7 @@ func TestParseInput(t *testing.T) { description: "required flags only (no values to update)", argValues: fixtureArgValues(), flagValues: map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, }, isValid: false, expectedModel: &inputModel{ @@ -150,17 +150,17 @@ func TestParseInput(t *testing.T) { 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: "", + globalflags.ProjectIdFlag: testProjectId, + nameFlag: "", + defaultTTLFlag: "0", + aclFlag: "", + primaryFlag: "", + retryTimeFlag: "0", + refreshTimeFlag: "0", + negativeCacheFlag: "0", + expireTimeFlag: "0", + descriptionFlag: "", + contactEmailFlag: "", }, isValid: true, expectedModel: &inputModel{ @@ -185,7 +185,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -193,7 +193,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -201,7 +201,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -246,7 +246,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/dns/zone/zone.go b/internal/cmd/dns/zone/zone.go index 77247e3c4..ecfb1240a 100644 --- a/internal/cmd/dns/zone/zone.go +++ b/internal/cmd/dns/zone/zone.go @@ -8,13 +8,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/list" "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "zone", Short: "Provides functionality for DNS zones", @@ -22,15 +22,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(clone.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(clone.NewCmd(params)) } diff --git a/internal/cmd/git/flavor/flavor.go b/internal/cmd/git/flavor/flavor.go new file mode 100644 index 000000000..ee1700900 --- /dev/null +++ b/internal/cmd/git/flavor/flavor.go @@ -0,0 +1,28 @@ +package flavor + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/git/flavor/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "flavor", + Short: "Provides functionality for STACKIT Git flavors", + Long: "Provides functionality for STACKIT Git flavors.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand( + list.NewCmd(params), + ) +} diff --git a/internal/cmd/git/flavor/list/list.go b/internal/cmd/git/flavor/list/list.go new file mode 100644 index 000000000..29f88573f --- /dev/null +++ b/internal/cmd/git/flavor/list/list.go @@ -0,0 +1,142 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/git" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +const limitFlag = "limit" + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists instances flavors of STACKIT Git.", + Long: "Lists instances flavors of STACKIT Git for the current project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List STACKIT Git flavors`, + "$ stackit git flavor list"), + examples.NewExample( + "Lists up to 10 STACKIT Git flavors", + "$ stackit git flavor list --limit=10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get STACKIT Git flavors: %w", err) + } + flavors := resp.GetFlavors() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Truncate output + if model.Limit != nil && len(flavors) > int(*model.Limit) { + flavors = (flavors)[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, flavors) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *git.APIClient) git.ApiListFlavorsRequest { + return apiClient.ListFlavors(ctx, model.ProjectId) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, flavors []git.Flavor) error { + return p.OutputResult(outputFormat, flavors, func() error { + if len(flavors) == 0 { + p.Outputf("No flavors found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "DESCRIPTION", "DISPLAY_NAME", "AVAILABLE", "SKU") + for i := range flavors { + flavor := (flavors)[i] + table.AddRow( + utils.PtrString(flavor.Id), + utils.PtrString(flavor.Description), + utils.PtrString(flavor.DisplayName), + utils.PtrString(flavor.Availability), + utils.PtrString(flavor.Sku), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/git/flavor/list/list_test.go b/internal/cmd/git/flavor/list/list_test.go new file mode 100644 index 000000000..81b4d7553 --- /dev/null +++ b/internal/cmd/git/flavor/list/list_test.go @@ -0,0 +1,203 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/git" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &git.APIClient{} +var testProjectId = uuid.NewString() + +const ( + testLimit = 10 +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *git.ApiListFlavorsRequest)) git.ApiListFlavorsRequest { + request := testClient.ListFlavors(testCtx, testProjectId) + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "with limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(testLimit) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(testLimit)) + }), + }, + { + description: "with limit flag == 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(0) + }), + isValid: false, + }, + { + description: "with limit flag < 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(-1) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest git.ApiListFlavorsRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + flavors []git.Flavor + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty flavors slice", + args: args{ + flavors: []git.Flavor{}, + }, + wantErr: false, + }, + { + name: "set empty flavors in flavors slice", + args: args{ + flavors: []git.Flavor{{}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.flavors); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/git/git.go b/internal/cmd/git/git.go new file mode 100644 index 000000000..624702686 --- /dev/null +++ b/internal/cmd/git/git.go @@ -0,0 +1,30 @@ +package git + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/git/flavor" + "github.com/stackitcloud/stackit-cli/internal/cmd/git/instance" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "git", + Short: "Provides functionality for STACKIT Git", + Long: "Provides functionality for STACKIT Git.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand( + instance.NewCmd(params), + flavor.NewCmd(params), + ) +} diff --git a/internal/cmd/git/instance/create/create.go b/internal/cmd/git/instance/create/create.go new file mode 100644 index 000000000..94adf0b52 --- /dev/null +++ b/internal/cmd/git/instance/create/create.go @@ -0,0 +1,160 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/git" + "github.com/stackitcloud/stackit-sdk-go/services/git/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" +) + +const ( + nameFlag = "name" + flavorFlag = "flavor" + aclFlag = "acl" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name string + Flavor string + Acl []string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates STACKIT Git instance", + Long: "Create a STACKIT Git instance by name.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a instance with name 'my-new-instance'`, + `$ stackit git instance create --name my-new-instance`, + ), + examples.NewExample( + `Create a instance with name 'my-new-instance' and flavor`, + `$ stackit git instance create --name my-new-instance --flavor git-100`, + ), + examples.NewExample( + `Create a instance with name 'my-new-instance' and acl`, + `$ stackit git instance create --name my-new-instance --acl 1.1.1.1/1`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) (err error) { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to create the instance %q?", model.Name) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + result, err := request.Execute() + if err != nil { + return fmt.Errorf("create stackit git instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating STACKIT git instance", func() error { + _, err = wait.CreateGitInstanceWaitHandler(ctx, apiClient, model.ProjectId, *result.Id).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for stackit git Instance creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, model.Name, result) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "The name of the instance.") + cmd.Flags().String(flavorFlag, "", "Flavor of the instance.") + cmd.Flags().StringSlice(aclFlag, []string{}, "Acl for the instance.") + if err := flags.MarkFlagsRequired(cmd, nameFlag); err != nil { + cobra.CheckErr(err) + } +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + name := flags.FlagToStringValue(p, cmd, nameFlag) + flavor := flags.FlagToStringValue(p, cmd, flavorFlag) + acl := flags.FlagToStringSliceValue(p, cmd, aclFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Name: name, + Flavor: flavor, + Acl: acl, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *git.APIClient) git.ApiCreateInstanceRequest { + return apiClient.CreateInstance(ctx, model.ProjectId).CreateInstancePayload(createPayload(model)) +} + +func createPayload(model *inputModel) git.CreateInstancePayload { + return git.CreateInstancePayload{ + Name: &model.Name, + Flavor: git.CreateInstancePayloadGetFlavorAttributeType(&model.Flavor), + Acl: &model.Acl, + } +} + +func outputResult(p *print.Printer, outputFormat string, async bool, instanceName string, resp *git.Instance) error { + if resp == nil { + return fmt.Errorf("API resp is nil") + } + if resp.Id == nil { + return fmt.Errorf("API resp is missing instance id") + } + + return p.OutputResult(outputFormat, resp, func() error { + operationState := "Created" + if async { + operationState = "Triggered creation of" + } + p.Outputf("%s instance %q with id %s\n", operationState, instanceName, *resp.Id) + return nil + }) +} diff --git a/internal/cmd/git/instance/create/create_test.go b/internal/cmd/git/instance/create/create_test.go new file mode 100644 index 000000000..b1056d786 --- /dev/null +++ b/internal/cmd/git/instance/create/create_test.go @@ -0,0 +1,229 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/git" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &git.APIClient{} + testProjectId = uuid.NewString() + + testName = "test-instance" + testFlavor = "git-100" + testAcl = []string{"0.0.0.0/0"} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + + nameFlag: testName, + flavorFlag: testFlavor, + aclFlag: testAcl[0], + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + Name: testName, + Flavor: testFlavor, + Acl: testAcl, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureCreatePayload(mods ...func(payload *git.CreateInstancePayload)) (payload git.CreateInstancePayload) { + payload = git.CreateInstancePayload{ + Name: &testName, + Flavor: git.CreateInstancePayloadGetFlavorAttributeType(&testFlavor), + Acl: &testAcl, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *git.ApiCreateInstanceRequest)) git.ApiCreateInstanceRequest { + request := testClient.CreateInstance(testCtx, testProjectId) + + request = request.CreateInstancePayload(fixtureCreatePayload()) + + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest git.ApiCreateInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "name flag", + model: fixtureInputModel(func(model *inputModel) { + model.Name = "new-name" + }), + expectedRequest: fixtureRequest(func(request *git.ApiCreateInstanceRequest) { + *request = (*request).CreateInstancePayload(fixtureCreatePayload(func(payload *git.CreateInstancePayload) { + payload.Name = utils.Ptr("new-name") + })) + }), + }, + } + + 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), + cmp.AllowUnexported(git.NullableString{}), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + instanceName string + resp *git.Instance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "nil response", + args: args{ + outputFormat: "", + async: false, + instanceName: "", + resp: nil, + }, + wantErr: true, + }, + { + name: "empty input", + args: args{ + outputFormat: "", + async: false, + instanceName: "", + resp: &git.Instance{Id: utils.Ptr(uuid.NewString())}, + }, + wantErr: false, + }, + { + name: "output json", + args: args{ + outputFormat: print.JSONOutputFormat, + async: true, + instanceName: testName, + resp: &git.Instance{Id: utils.Ptr(uuid.NewString())}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.instanceName, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/git/instance/delete/delete.go b/internal/cmd/git/instance/delete/delete.go new file mode 100644 index 000000000..b3d18146c --- /dev/null +++ b/internal/cmd/git/instance/delete/delete.go @@ -0,0 +1,124 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/git" + "github.com/stackitcloud/stackit-sdk-go/services/git/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/client" + gitUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +const instanceIdArg = "INSTANCE_ID" + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Deletes STACKIT Git instance", + Long: "Deletes a STACKIT Git instance by its internal ID.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Delete a instance with ID "xxx"`, + `$ stackit git instance delete xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectName, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectName = model.ProjectId + } + + instanceName, err := gitUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get stackit git intance name: %v", err) + instanceName = model.InstanceId + } else if instanceName == "" { + instanceName = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the stackit git instance %q for %q?", instanceName, projectName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + err = request.Execute() + if err != nil { + return fmt.Errorf("delete instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting STACKIT git instance", func() error { + _, err = wait.DeleteGitInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for stackit git instance deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Info("%s stackit git instance %s \n", operationState, model.InstanceId) + + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: cliArgs[0], + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *git.APIClient) git.ApiDeleteInstanceRequest { + return apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) +} diff --git a/internal/cmd/git/instance/delete/delete_test.go b/internal/cmd/git/instance/delete/delete_test.go new file mode 100644 index 000000000..7b6a47fd9 --- /dev/null +++ b/internal/cmd/git/instance/delete/delete_test.go @@ -0,0 +1,184 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &git.APIClient{} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *git.ApiDeleteInstanceRequest)) git.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 + args []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + args: []string{testInstanceId}, + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "no arguments", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "multiple arguments", + flagValues: fixtureFlagValues(), + args: []string{"foo", "bar"}, + isValid: false, + }, + { + description: "invalid instance id", + flagValues: fixtureFlagValues(), + args: []string{"foo"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + cmd.SetArgs(tt.args) + + 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 err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.args) + 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 git.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/git/instance/describe/describe.go b/internal/cmd/git/instance/describe/describe.go new file mode 100644 index 000000000..1ee2af24d --- /dev/null +++ b/internal/cmd/git/instance/describe/describe.go @@ -0,0 +1,127 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/git" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +const instanceIdArg = "INSTANCE_ID" + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Describes STACKIT Git instance", + Long: "Describes a STACKIT Git instance by its internal ID.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample(`Describe instance "xxx"`, `$ stackit git describe xxx`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + instance, err := request.Execute() + if err != nil { + return fmt.Errorf("get instance: %w", err) + } + + if err := outputResult(params.Printer, model.OutputFormat, instance); err != nil { + return err + } + + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: cliArgs[0], + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *git.APIClient) git.ApiGetInstanceRequest { + return apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) +} + +func outputResult(p *print.Printer, outputFormat string, resp *git.Instance) error { + if resp == nil { + return fmt.Errorf("instance not found") + } + + return p.OutputResult(outputFormat, resp, func() error { + table := tables.NewTable() + if id := resp.Id; id != nil { + table.AddRow("ID", *id) + table.AddSeparator() + } + if name := resp.Name; name != nil { + table.AddRow("NAME", *name) + table.AddSeparator() + } + if url := resp.Url; url != nil { + table.AddRow("URL", *url) + table.AddSeparator() + } + if version := resp.Version; version != nil { + table.AddRow("VERSION", *version) + table.AddSeparator() + } + if state := resp.State; state != nil { + table.AddRow("STATE", *state) + table.AddSeparator() + } + if created := resp.Created; created != nil { + table.AddRow("CREATED", *created) + table.AddSeparator() + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/git/instance/describe/describe_test.go b/internal/cmd/git/instance/describe/describe_test.go new file mode 100644 index 000000000..9b21d375a --- /dev/null +++ b/internal/cmd/git/instance/describe/describe_test.go @@ -0,0 +1,229 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/git" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &git.APIClient{} + testProjectId = uuid.NewString() + testInstanceId = []string{uuid.NewString()} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + InstanceId: testInstanceId[0], + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *git.ApiGetInstanceRequest)) git.ApiGetInstanceRequest { + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId[0]) + 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 + args []string + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + expectedModel: fixtureInputModel(), + args: testInstanceId, + isValid: true, + }, + { + description: "no values", + flagValues: map[string]string{}, + args: testInstanceId, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + args: testInstanceId, + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + args: testInstanceId, + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + args: testInstanceId, + isValid: false, + }, + { + description: "no instance id passed", + flagValues: fixtureFlagValues(), + args: nil, + isValid: false, + }, + { + description: "multiple instance ids passed", + flagValues: fixtureFlagValues(), + args: []string{uuid.NewString(), uuid.NewString()}, + isValid: false, + }, + { + description: "invalid instance id passed", + flagValues: fixtureFlagValues(), + args: []string{"foobar"}, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + if err := cmd.ValidateArgs(tt.args); err != nil { + if !tt.isValid { + return + } + } + + model, err := parseInput(p, cmd, tt.args) + 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 git.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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + resp *git.Instance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + resp: &git.Instance{}, + }, + wantErr: false, + }, + { + name: "nil", + args: args{}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/git/instance/instance.go b/internal/cmd/git/instance/instance.go new file mode 100644 index 000000000..bd36a1cbd --- /dev/null +++ b/internal/cmd/git/instance/instance.go @@ -0,0 +1,34 @@ +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/git/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/git/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/git/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/git/instance/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for STACKIT Git instances", + Long: "Provides functionality for STACKIT Git instances.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand( + list.NewCmd(params), + describe.NewCmd(params), + create.NewCmd(params), + delete.NewCmd(params), + ) +} diff --git a/internal/cmd/git/instance/list/list.go b/internal/cmd/git/instance/list/list.go new file mode 100644 index 000000000..4e8ec548b --- /dev/null +++ b/internal/cmd/git/instance/list/list.go @@ -0,0 +1,143 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/git" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/git/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +const limitFlag = "limit" + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all instances of STACKIT Git.", + Long: "Lists all instances of STACKIT Git for the current project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all STACKIT Git instances`, + "$ stackit git instance list"), + examples.NewExample( + "Lists up to 10 STACKIT Git instances", + "$ stackit git instance list --limit=10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get STACKIT Git instances: %w", err) + } + instances := resp.GetInstances() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Truncate output + if model.Limit != nil && len(instances) > int(*model.Limit) { + instances = (instances)[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *git.APIClient) git.ApiListInstancesRequest { + return apiClient.ListInstances(ctx, model.ProjectId) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []git.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "URL", "VERSION", "STATE", "CREATED") + for i := range instances { + instance := (instances)[i] + table.AddRow( + utils.PtrString(instance.Id), + utils.PtrString(instance.Name), + utils.PtrString(instance.Url), + utils.PtrString(instance.Version), + utils.PtrString(instance.State), + utils.PtrString(instance.Created), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/git/instance/list/list_test.go b/internal/cmd/git/instance/list/list_test.go new file mode 100644 index 000000000..c065bb8da --- /dev/null +++ b/internal/cmd/git/instance/list/list_test.go @@ -0,0 +1,203 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/git" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &git.APIClient{} +var testProjectId = uuid.NewString() + +const ( + testLimit = 10 +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *git.ApiListInstancesRequest)) git.ApiListInstancesRequest { + request := testClient.ListInstances(testCtx, testProjectId) + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "with limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(testLimit) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(testLimit)) + }), + }, + { + description: "with limit flag == 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(0) + }), + isValid: false, + }, + { + description: "with limit flag < 0", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["limit"] = strconv.Itoa(-1) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest git.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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + instances []git.Instance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty instances slice", + args: args{ + instances: []git.Instance{}, + }, + wantErr: false, + }, + { + name: "set empty instances in instances slice", + args: args{ + instances: []git.Instance{{}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/image/create/create.go b/internal/cmd/image/create/create.go index b29048efb..fd1a20975 100644 --- a/internal/cmd/image/create/create.go +++ b/internal/cmd/image/create/create.go @@ -3,7 +3,6 @@ package create import ( "bufio" "context" - "encoding/json" goerrors "errors" "fmt" "io" @@ -11,8 +10,11 @@ import ( "os" "time" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -21,7 +23,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -30,6 +31,7 @@ const ( localFilePathFlag = "local-file-path" noProgressIndicatorFlag = "no-progress" + architectureFlag = "architecture" bootMenuFlag = "boot-menu" cdromBusFlag = "cdrom-bus" diskBusFlag = "disk-bus" @@ -52,6 +54,7 @@ const ( ) type imageConfig struct { + Architecture *string BootMenu *bool CdromBus *string DiskBus *string @@ -81,7 +84,7 @@ type inputModel struct { NoProgressIndicator *bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates images", @@ -96,16 +99,20 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create an image with name 'my-new-image' from a qcow2 image read from '/my/qcow2/image' with labels describing its contents`, `$ stackit image create --name my-new-image --disk-format=qcow2 --local-file-path=/my/qcow2/image --labels os=linux,distro=alpine,version=3.12`, ), + examples.NewExample( + `Create an image with name 'my-new-image' from a raw disk image located in '/my/raw/image' with uefi disabled`, + `$ stackit image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image --uefi=false`, + ), ), - RunE: func(cmd *cobra.Command, _ []string) (err error) { + RunE: func(cmd *cobra.Command, args []string) (err error) { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -121,12 +128,10 @@ func NewCmd(p *print.Printer) *cobra.Command { } }() - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create the image %q?", model.Name) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create the image %q?", model.Name) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -141,11 +146,11 @@ func NewCmd(p *print.Printer) *cobra.Command { if !ok { return fmt.Errorf("create image: no upload URL has been provided") } - if err := uploadAsync(ctx, p, model, file, url); err != nil { + if err := uploadAsync(ctx, params.Printer, model, file, url); err != nil { return err } - if err := outputResult(p, model, result); err != nil { + if err := outputResult(params.Printer, model, result); err != nil { return err } @@ -260,6 +265,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(localFilePathFlag, "", "The path to the local disk image file.") cmd.Flags().Bool(noProgressIndicatorFlag, false, "Show no progress indicator for upload.") + cmd.Flags().String(architectureFlag, "", "Sets the CPU architecture. By default x86 is used.") cmd.Flags().Bool(bootMenuFlag, false, "Enables the BIOS bootmenu.") cmd.Flags().String(cdromBusFlag, "", "Sets CDROM bus controller type.") cmd.Flags().String(diskBusFlag, "", "Sets Disk bus controller type.") @@ -286,7 +292,7 @@ func configureFlags(cmd *cobra.Command) { cmd.MarkFlagsRequiredTogether(rescueBusFlag, rescueDeviceFlag) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -301,6 +307,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), NoProgressIndicator: flags.FlagToBoolPointer(p, cmd, noProgressIndicatorFlag), Config: &imageConfig{ + Architecture: flags.FlagToStringPointer(p, cmd, architectureFlag), BootMenu: flags.FlagToBoolPointer(p, cmd, bootMenuFlag), CdromBus: flags.FlagToStringPointer(p, cmd, cdromBusFlag), DiskBus: flags.FlagToStringPointer(p, cmd, diskBusFlag), @@ -320,56 +327,66 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Protected: flags.FlagToBoolPointer(p, cmd, protectedFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateImageRequest { - request := apiClient.CreateImage(ctx, model.ProjectId). + request := apiClient.CreateImage(ctx, model.ProjectId, model.Region). CreateImagePayload(createPayload(ctx, model)) return request } func createPayload(_ context.Context, model *inputModel) iaas.CreateImagePayload { - var labelsMap *map[string]any - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } payload := iaas.CreateImagePayload{ DiskFormat: &model.DiskFormat, Name: &model.Name, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), MinDiskSize: model.MinDiskSize, MinRam: model.MinRam, Protected: model.Protected, } - if model.Config != nil { - payload.Config = &iaas.ImageConfig{ - BootMenu: model.Config.BootMenu, - CdromBus: iaas.NewNullableString(model.Config.CdromBus), - DiskBus: iaas.NewNullableString(model.Config.DiskBus), - NicModel: iaas.NewNullableString(model.Config.NicModel), - OperatingSystem: model.Config.OperatingSystem, - OperatingSystemDistro: iaas.NewNullableString(model.Config.OperatingSystemDistro), - OperatingSystemVersion: iaas.NewNullableString(model.Config.OperatingSystemVersion), - RescueBus: iaas.NewNullableString(model.Config.RescueBus), - RescueDevice: iaas.NewNullableString(model.Config.RescueDevice), - SecureBoot: model.Config.SecureBoot, - Uefi: utils.Ptr(model.Config.Uefi), - VideoModel: iaas.NewNullableString(model.Config.VideoModel), - VirtioScsi: model.Config.VirtioScsi, + if config := model.Config; config != nil { + payload.Config = &iaas.ImageConfig{} + payload.Config.Uefi = utils.Ptr(config.Uefi) + if config.Architecture != nil { + payload.Config.Architecture = model.Config.Architecture + } + if config.BootMenu != nil { + payload.Config.BootMenu = model.Config.BootMenu + } + if config.CdromBus != nil { + payload.Config.CdromBus = iaas.NewNullableString(model.Config.CdromBus) + } + if config.DiskBus != nil { + payload.Config.DiskBus = iaas.NewNullableString(config.DiskBus) + } + if config.NicModel != nil { + payload.Config.NicModel = iaas.NewNullableString(config.NicModel) + } + if config.OperatingSystem != nil { + payload.Config.OperatingSystem = config.OperatingSystem + } + if config.OperatingSystemDistro != nil { + payload.Config.OperatingSystemDistro = iaas.NewNullableString(config.OperatingSystemDistro) + } + if config.OperatingSystemVersion != nil { + payload.Config.OperatingSystemVersion = iaas.NewNullableString(config.OperatingSystemVersion) + } + if config.RescueBus != nil { + payload.Config.RescueBus = iaas.NewNullableString(config.RescueBus) + } + if config.RescueDevice != nil { + payload.Config.RescueDevice = iaas.NewNullableString(config.RescueDevice) + } + if config.SecureBoot != nil { + payload.Config.SecureBoot = config.SecureBoot + } + if config.VideoModel != nil { + payload.Config.VideoModel = iaas.NewNullableString(config.VideoModel) + } + if config.VirtioScsi != nil { + payload.Config.VirtioScsi = config.VirtioScsi } } @@ -384,25 +401,9 @@ func outputResult(p *print.Printer, model *inputModel, resp *iaas.ImageCreateRes if model.GlobalFlagModel != nil { outputFormat = model.OutputFormat } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal image: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal image: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created image %q with id %s\n", model.Name, utils.PtrString(model.Id)) return nil - } + }) } diff --git a/internal/cmd/image/create/create_test.go b/internal/cmd/image/create/create_test.go index 262dc029e..bb437946a 100644 --- a/internal/cmd/image/create/create_test.go +++ b/internal/cmd/image/create/create_test.go @@ -6,24 +6,21 @@ import ( "strings" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag - -type testCtxKey struct{} - -var ( - testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") - testClient = &iaas.APIClient{} - testProjectId = uuid.NewString() - +const ( + testRegion = "eu01" testLocalImagePath = "/does/not/exist" testDiskFormat = "raw" testDiskSize int64 = 16 * 1024 * 1024 * 1024 @@ -38,6 +35,7 @@ var ( testOperatingSystemVersion = "test-distro-version" testRescueBus = "test-rescue-bus" testRescueDevice = "test-rescue-device" + testArchitecture = "arm64" testBootmenu = true testSecureBoot = true testUefi = true @@ -46,13 +44,23 @@ var ( testLabels = "foo=FOO,bar=BAR,baz=BAZ" ) +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() +) + func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, nameFlag: testName, diskFormatFlag: testDiskFormat, localFilePathFlag: testLocalImagePath, + architectureFlag: testArchitecture, bootMenuFlag: strconv.FormatBool(testBootmenu), cdromBusFlag: testCdRomBus, diskBusFlag: testDiskBus, @@ -89,29 +97,34 @@ func parseLabels(labelstring string) map[string]string { func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, - Name: testName, - DiskFormat: testDiskFormat, - LocalFilePath: testLocalImagePath, - Labels: utils.Ptr(parseLabels(testLabels)), + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Name: testName, + DiskFormat: testDiskFormat, + LocalFilePath: testLocalImagePath, + Labels: utils.Ptr(parseLabels(testLabels)), Config: &imageConfig{ - BootMenu: &testBootmenu, - CdromBus: &testCdRomBus, - DiskBus: &testDiskBus, - NicModel: &testNicModel, - OperatingSystem: &testOperatingSystem, - OperatingSystemDistro: &testOperatingSystemDistro, - OperatingSystemVersion: &testOperatingSystemVersion, - RescueBus: &testRescueBus, - RescueDevice: &testRescueDevice, - SecureBoot: &testSecureBoot, + Architecture: utils.Ptr(testArchitecture), + BootMenu: utils.Ptr(testBootmenu), + CdromBus: utils.Ptr(testCdRomBus), + DiskBus: utils.Ptr(testDiskBus), + NicModel: utils.Ptr(testNicModel), + OperatingSystem: utils.Ptr(testOperatingSystem), + OperatingSystemDistro: utils.Ptr(testOperatingSystemDistro), + OperatingSystemVersion: utils.Ptr(testOperatingSystemVersion), + RescueBus: utils.Ptr(testRescueBus), + RescueDevice: utils.Ptr(testRescueDevice), + SecureBoot: utils.Ptr(testSecureBoot), Uefi: testUefi, - VideoModel: &testVideoModel, - VirtioScsi: &testVirtioScsi, + VideoModel: utils.Ptr(testVideoModel), + VirtioScsi: utils.Ptr(testVirtioScsi), }, - MinDiskSize: &testDiskSize, - MinRam: &testRamSize, - Protected: &testProtected, + MinDiskSize: utils.Ptr(testDiskSize), + MinRam: utils.Ptr(testRamSize), + Protected: utils.Ptr(testProtected), } for _, mod := range mods { mod(model) @@ -122,30 +135,31 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { func fixtureCreatePayload(mods ...func(payload *iaas.CreateImagePayload)) (payload iaas.CreateImagePayload) { payload = iaas.CreateImagePayload{ Config: &iaas.ImageConfig{ - BootMenu: &testBootmenu, - CdromBus: iaas.NewNullableString(&testCdRomBus), - DiskBus: iaas.NewNullableString(&testDiskBus), - NicModel: iaas.NewNullableString(&testNicModel), - OperatingSystem: &testOperatingSystem, - OperatingSystemDistro: iaas.NewNullableString(&testOperatingSystemDistro), - OperatingSystemVersion: iaas.NewNullableString(&testOperatingSystemVersion), - RescueBus: iaas.NewNullableString(&testRescueBus), - RescueDevice: iaas.NewNullableString(&testRescueDevice), - SecureBoot: &testSecureBoot, - Uefi: &testUefi, - VideoModel: iaas.NewNullableString(&testVideoModel), - VirtioScsi: &testVirtioScsi, + Architecture: utils.Ptr(testArchitecture), + BootMenu: utils.Ptr(testBootmenu), + CdromBus: iaas.NewNullableString(utils.Ptr(testCdRomBus)), + DiskBus: iaas.NewNullableString(utils.Ptr(testDiskBus)), + NicModel: iaas.NewNullableString(utils.Ptr(testNicModel)), + OperatingSystem: utils.Ptr(testOperatingSystem), + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr(testOperatingSystemDistro)), + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr(testOperatingSystemVersion)), + RescueBus: iaas.NewNullableString(utils.Ptr(testRescueBus)), + RescueDevice: iaas.NewNullableString(utils.Ptr(testRescueDevice)), + SecureBoot: utils.Ptr(testSecureBoot), + Uefi: utils.Ptr(testUefi), + VideoModel: iaas.NewNullableString(utils.Ptr(testVideoModel)), + VirtioScsi: utils.Ptr(testVirtioScsi), }, - DiskFormat: &testDiskFormat, + DiskFormat: utils.Ptr(testDiskFormat), Labels: &map[string]interface{}{ "foo": "FOO", "bar": "BAR", "baz": "BAZ", }, - MinDiskSize: &testDiskSize, - MinRam: &testRamSize, - Name: &testName, - Protected: &testProtected, + MinDiskSize: utils.Ptr(testDiskSize), + MinRam: utils.Ptr(testRamSize), + Name: utils.Ptr(testName), + Protected: utils.Ptr(testProtected), } for _, mod := range mods { mod(&payload) @@ -154,7 +168,7 @@ func fixtureCreatePayload(mods ...func(payload *iaas.CreateImagePayload)) (paylo } func fixtureRequest(mods ...func(request *iaas.ApiCreateImageRequest)) iaas.ApiCreateImageRequest { - request := testClient.CreateImage(testCtx, testProjectId) + request := testClient.CreateImage(testCtx, testProjectId, testRegion) request = request.CreateImagePayload(fixtureCreatePayload()) @@ -167,6 +181,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateImageRequest)) iaas.ApiC func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -185,21 +200,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -272,51 +287,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateFlagGroups(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flag groups: %v", err) - } - - if err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -338,7 +309,7 @@ func TestBuildRequest(t *testing.T) { model.Labels = nil }), expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) { - *request = request.CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { + *request = (*request).CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { payload.Labels = nil })) }), @@ -349,7 +320,7 @@ func TestBuildRequest(t *testing.T) { model.Config.CdromBus = utils.Ptr("foobar") }), expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) { - *request = request.CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { + *request = (*request).CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { payload.Config.CdromBus = iaas.NewNullableString(utils.Ptr("foobar")) })) }), @@ -360,7 +331,7 @@ func TestBuildRequest(t *testing.T) { model.Config.Uefi = false }), expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) { - *request = request.CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { + *request = (*request).CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) { payload.Config.Uefi = utils.Ptr(false) })) }), @@ -422,7 +393,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/image/delete/delete.go b/internal/cmd/image/delete/delete.go index 57ae4f026..b41431710 100644 --- a/internal/cmd/image/delete/delete.go +++ b/internal/cmd/image/delete/delete.go @@ -4,9 +4,13 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { @@ -24,7 +27,7 @@ type inputModel struct { const imageIdArg = "IMAGE_ID" -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", imageIdArg), Short: "Deletes an image", @@ -35,37 +38,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - imageName, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.ImageId) + imageName, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.Region, model.ImageId) if err != nil { - p.Debug(print.ErrorLevel, "get image name: %v", err) - imageName = model.ImageId - } else if imageName == "" { + params.Printer.Debug(print.ErrorLevel, "get image name: %v", err) imageName = model.ImageId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete the image %q for %q?", imageName, projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete the image %q for %q?", imageName, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -74,7 +73,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if err := request.Execute(); err != nil { return fmt.Errorf("delete image: %w", err) } - p.Info("Deleted image %q for %q\n", imageName, projectLabel) + params.Printer.Info("Deleted image %q for %q\n", imageName, projectLabel) return nil }, @@ -86,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ @@ -94,19 +93,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM ImageId: cliArgs[0], } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteImageRequest { - request := apiClient.DeleteImage(ctx, model.ProjectId, model.ImageId) + request := apiClient.DeleteImage(ctx, model.ProjectId, model.Region, model.ImageId) return request } diff --git a/internal/cmd/image/delete/delete_test.go b/internal/cmd/image/delete/delete_test.go index 1fa1ed5bc..200af7e6c 100644 --- a/internal/cmd/image/delete/delete_test.go +++ b/internal/cmd/image/delete/delete_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -13,7 +15,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -26,7 +30,8 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -36,8 +41,12 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, - ImageId: testImageId, + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ImageId: testImageId, } for _, mod := range mods { mod(model) @@ -46,7 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteImageRequest)) iaas.ApiDeleteImageRequest { - request := testClient.DeleteImage(testCtx, testProjectId, testImageId) + request := testClient.DeleteImage(testCtx, testProjectId, testRegion, testImageId) for _, mod := range mods { mod(&request) } @@ -56,6 +65,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiDeleteImageRequest)) iaas.ApiD func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string args []string isValid bool @@ -71,14 +81,14 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -105,7 +115,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/image/describe/describe.go b/internal/cmd/image/describe/describe.go index 20ab802fc..9bcdd4ca5 100644 --- a/internal/cmd/image/describe/describe.go +++ b/internal/cmd/image/describe/describe.go @@ -2,12 +2,14 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { @@ -26,7 +27,7 @@ type inputModel struct { const imageIdArg = "IMAGE_ID" -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", imageIdArg), Short: "Describes image", @@ -37,13 +38,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -56,7 +57,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get image: %w", err) } - if err := outputResult(p, model.OutputFormat, image); err != nil { + if err := outputResult(params.Printer, model.OutputFormat, image); err != nil { return err } @@ -68,7 +69,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetImageRequest { - request := apiClient.GetImage(ctx, model.ProjectId, model.ImageId) + request := apiClient.GetImage(ctx, model.ProjectId, model.Region, model.ImageId) return request } @@ -83,15 +84,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM ImageId: cliArgs[0], } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -99,34 +92,21 @@ func outputResult(p *print.Printer, outputFormat string, resp *iaas.Image) error if resp == nil { return fmt.Errorf("image not found") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal image: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal image: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { table := tables.NewTable() if id := resp.Id; id != nil { table.AddRow("ID", *id) + table.AddSeparator() } - table.AddSeparator() - if name := resp.Name; name != nil { table.AddRow("NAME", *name) table.AddSeparator() } + if status := resp.Status; status != nil { + table.AddRow("STATUS", *status) + table.AddSeparator() + } if format := resp.DiskFormat; format != nil { table.AddRow("FORMAT", *format) table.AddSeparator() @@ -140,6 +120,10 @@ func outputResult(p *print.Printer, outputFormat string, resp *iaas.Image) error table.AddSeparator() } if config := resp.Config; config != nil { + if architecture := config.Architecture; architecture != nil { + table.AddRow("ARCHITECTURE", *architecture) + table.AddSeparator() + } if os := config.OperatingSystem; os != nil { table.AddRow("OPERATING SYSTEM", *os) table.AddSeparator() @@ -172,5 +156,5 @@ func outputResult(p *print.Printer, outputFormat string, resp *iaas.Image) error } return nil - } + }) } diff --git a/internal/cmd/image/describe/describe_test.go b/internal/cmd/image/describe/describe_test.go index 036648fee..2d9bd7fb8 100644 --- a/internal/cmd/image/describe/describe_test.go +++ b/internal/cmd/image/describe/describe_test.go @@ -4,6 +4,9 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -26,7 +31,8 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -36,8 +42,12 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, - ImageId: testImageId[0], + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + ImageId: testImageId[0], } for _, mod := range mods { mod(model) @@ -46,7 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetImageRequest)) iaas.ApiGetImageRequest { - request := testClient.GetImage(testCtx, testProjectId, testImageId[0]) + request := testClient.GetImage(testCtx, testProjectId, testRegion, testImageId[0]) for _, mod := range mods { mod(&request) } @@ -56,6 +66,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiGetImageRequest)) iaas.ApiGetI func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool args []string @@ -77,7 +88,7 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), args: testImageId, isValid: false, @@ -85,7 +96,7 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), args: testImageId, isValid: false, @@ -93,7 +104,7 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), args: testImageId, isValid: false, @@ -120,7 +131,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) if err := globalflags.Configure(cmd.Flags()); err != nil { t.Errorf("cannot configure global flags: %v", err) } @@ -215,9 +226,36 @@ func TestOutputResult(t *testing.T) { args: args{}, wantErr: true, }, + { + name: "valid value", + args: args{ + resp: &iaas.Image{ + Id: utils.Ptr(uuid.NewString()), + Name: utils.Ptr("Image"), + Status: utils.Ptr("STATUS"), + DiskFormat: utils.Ptr("format"), + MinDiskSize: utils.Ptr(int64(0)), + MinRam: utils.Ptr(int64(0)), + Config: &iaas.ImageConfig{ + Architecture: utils.Ptr("architecture"), + OperatingSystem: utils.Ptr("os"), + OperatingSystemDistro: iaas.NewNullableString(utils.Ptr("os distro")), + OperatingSystemVersion: iaas.NewNullableString(utils.Ptr("0.00.0")), + Uefi: utils.Ptr(true), + }, + Labels: utils.Ptr(map[string]any{ + "label1": true, + "label2": false, + "label3": 42, + "foo": "bar", + }), + }, + }, + wantErr: false, + }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/image/image.go b/internal/cmd/image/image.go index 899a62ce9..65a0cc2a5 100644 --- a/internal/cmd/image/image.go +++ b/internal/cmd/image/image.go @@ -7,14 +7,14 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/image/list" "github.com/stackitcloud/stackit-cli/internal/cmd/image/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "image", Short: "Manage server images", @@ -22,16 +22,16 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand( - create.NewCmd(p), - list.NewCmd(p), - delete.NewCmd(p), - describe.NewCmd(p), - update.NewCmd(p), + create.NewCmd(params), + list.NewCmd(params), + delete.NewCmd(params), + describe.NewCmd(params), + update.NewCmd(params), ) } diff --git a/internal/cmd/image/list/list.go b/internal/cmd/image/list/list.go index 47779fb83..ba21fbe84 100644 --- a/internal/cmd/image/list/list.go +++ b/internal/cmd/image/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,21 +19,22 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { *globalflags.GlobalFlagModel LabelSelector *string Limit *int64 + All *bool } const ( labelSelectorFlag = "label-selector" limitFlag = "limit" + allFlag = "all" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists images", @@ -39,7 +42,7 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `List all images`, + `List images in your project`, `$ stackit image list`, ), examples.NewExample( @@ -50,23 +53,27 @@ func NewCmd(p *print.Printer) *cobra.Command { `List the first 10 images`, `$ stackit image list --limit=10`, ), + examples.NewExample( + `List all images`, + `$ stackit image list --all`, + ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId @@ -81,12 +88,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } if items := response.GetItems(); len(items) == 0 { - p.Info("No images found for project %q", projectLabel) + params.Printer.Info("No images found for project %q", projectLabel) } else { if model.Limit != nil && len(items) > int(*model.Limit) { items = (items)[:*model.Limit] } - if err := outputResult(p, model.OutputFormat, items); err != nil { + if err := outputResult(params.Printer, model.OutputFormat, items); err != nil { return fmt.Errorf("output images: %w", err) } } @@ -102,9 +109,10 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().String(labelSelectorFlag, "", "Filter by label") cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") + cmd.Flags().Bool(allFlag, false, "List all images available") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -122,56 +130,43 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), Limit: limit, + All: flags.FlagToBoolPointer(p, cmd, allFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListImagesRequest { - request := apiClient.ListImages(ctx, model.ProjectId) + request := apiClient.ListImages(ctx, model.ProjectId, model.Region) if model.LabelSelector != nil { request = request.LabelSelector(*model.LabelSelector) } + if model.All != nil { + request = request.All(*model.All) + } return request } -func outputResult(p *print.Printer, outputFormat string, items []iaas.Image) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(items, "", " ") - if err != nil { - return fmt.Errorf("marshal image list: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal image list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: +func outputResult(p *print.Printer, outputFormat string, items []iaas.Image) error { + return p.OutputResult(outputFormat, items, func() error { table := tables.NewTable() - table.SetHeader("ID", "NAME", "OS", "DISTRIBUTION", "VERSION", "LABELS") - for _, item := range items { + table.SetHeader("ID", "NAME", "OS", "ARCHITECTURE", "DISTRIBUTION", "VERSION", "SCOPE", "OWNER", "LABELS") + for i := range items { + item := items[i] var ( - os string = "n/a" - distro string = "n/a" - version string = "n/a" + architecture = "n/a" + os = "n/a" + distro = "n/a" + version = "n/a" + owner = "n/a" + scope = "n/a" ) if cfg := item.Config; cfg != nil { + if v := cfg.Architecture; v != nil { + architecture = *v + } if v := cfg.OperatingSystem; v != nil { os = *v } @@ -182,11 +177,21 @@ func outputResult(p *print.Printer, outputFormat string, items []iaas.Image) err version = *v.Get() } } + if v := item.GetOwner(); v != "" { + owner = v + } + if v := item.GetScope(); v != "" { + scope = v + } + table.AddRow(utils.PtrString(item.Id), utils.PtrString(item.Name), os, + architecture, distro, version, + scope, + owner, utils.JoinStringKeysPtr(*item.Labels, ",")) } err := table.Display(p) @@ -195,5 +200,5 @@ func outputResult(p *print.Printer, outputFormat string, items []iaas.Image) err } return nil - } + }) } diff --git a/internal/cmd/image/list/list_test.go b/internal/cmd/image/list/list_test.go index 49a27a16e..7521d2023 100644 --- a/internal/cmd/image/list/list_test.go +++ b/internal/cmd/image/list/list_test.go @@ -5,8 +5,11 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -15,7 +18,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -29,7 +34,9 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + labelSelectorFlag: testLabels, limitFlag: strconv.Itoa(int(testLimit)), } @@ -41,9 +48,13 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, - LabelSelector: utils.Ptr(testLabels), - Limit: &testLimit, + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + LabelSelector: utils.Ptr(testLabels), + Limit: &testLimit, } for _, mod := range mods { mod(model) @@ -52,7 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListImagesRequest)) iaas.ApiListImagesRequest { - request := testClient.ListImages(testCtx, testProjectId) + request := testClient.ListImages(testCtx, testProjectId, testRegion) request = request.LabelSelector(testLabels) for _, mod := range mods { mod(&request) @@ -63,6 +74,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListImagesRequest)) iaas.ApiLi func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -81,21 +93,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -123,44 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -183,7 +158,7 @@ func TestBuildRequest(t *testing.T) { model.LabelSelector = utils.Ptr("") }), expectedRequest: fixtureRequest(func(request *iaas.ApiListImagesRequest) { - *request = request.LabelSelector("") + *request = (*request).LabelSelector("") }), }, { @@ -192,7 +167,7 @@ func TestBuildRequest(t *testing.T) { model.LabelSelector = utils.Ptr("foo=bar") }), expectedRequest: fixtureRequest(func(request *iaas.ApiListImagesRequest) { - *request = request.LabelSelector("foo=bar") + *request = (*request).LabelSelector("foo=bar") }), }, } @@ -239,7 +214,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.items); (err != nil) != tt.wantErr { diff --git a/internal/cmd/image/update/update.go b/internal/cmd/image/update/update.go index 8e6a8a2dd..f039a1fac 100644 --- a/internal/cmd/image/update/update.go +++ b/internal/cmd/image/update/update.go @@ -4,9 +4,13 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -15,10 +19,10 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type imageConfig struct { + Architecture *string BootMenu *bool CdromBus *string DiskBus *string @@ -79,6 +83,7 @@ const ( nameFlag = "name" diskFormatFlag = "disk-format" + architectureFlag = "architecture" bootMenuFlag = "boot-menu" cdromBusFlag = "cdrom-bus" diskBusFlag = "disk-bus" @@ -97,11 +102,10 @@ const ( minDiskSizeFlag = "min-disk-size" minRamFlag = "min-ram" - ownerFlag = "owner" protectedFlag = "protected" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", imageIdArg), Short: "Updates an image", @@ -113,37 +117,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - imageLabel, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.Id) + imageLabel, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.Region, model.Id) if err != nil { - p.Debug(print.WarningLevel, "cannot retrieve image name: %v", err) - imageLabel = model.Id - } else if imageLabel == "" { + params.Printer.Debug(print.WarningLevel, "cannot retrieve image name: %v", err) imageLabel = model.Id } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update the image %q?", imageLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update the image %q?", imageLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -153,7 +153,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("update image: %w", err) } - p.Info("Updated image \"%v\" for %q\n", utils.PtrString(resp.Name), projectLabel) + params.Printer.Info("Updated image \"%v\" for %q\n", utils.PtrString(resp.Name), projectLabel) return nil }, @@ -167,6 +167,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(nameFlag, "", "The name of the image.") cmd.Flags().String(diskFormatFlag, "", "The disk format of the image. ") + cmd.Flags().String(architectureFlag, "", "Sets the CPU architecture.") cmd.Flags().Bool(bootMenuFlag, false, "Enables the BIOS bootmenu.") cmd.Flags().String(cdromBusFlag, "", "Sets CDROM bus controller type.") cmd.Flags().String(diskBusFlag, "", "Sets Disk bus controller type.") @@ -193,7 +194,7 @@ func configureFlags(cmd *cobra.Command) { func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ @@ -204,6 +205,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM DiskFormat: flags.FlagToStringPointer(p, cmd, diskFormatFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag), Config: &imageConfig{ + Architecture: flags.FlagToStringPointer(p, cmd, architectureFlag), BootMenu: flags.FlagToBoolPointer(p, cmd, bootMenuFlag), CdromBus: flags.FlagToStringPointer(p, cmd, cdromBusFlag), DiskBus: flags.FlagToStringPointer(p, cmd, diskBusFlag), @@ -227,52 +229,67 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM return nil, fmt.Errorf("no flags have been passed") } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + if model.Config.isEmpty() { + model.Config = nil } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateImageRequest { - request := apiClient.UpdateImage(ctx, model.ProjectId, model.Id) + request := apiClient.UpdateImage(ctx, model.ProjectId, model.Region, model.Id) payload := iaas.NewUpdateImagePayload() - var labelsMap *map[string]any - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } + // Config *ImageConfig `json:"config,omitempty"` payload.DiskFormat = model.DiskFormat - payload.Labels = labelsMap + payload.Labels = utils.ConvertStringMapToInterfaceMap(model.Labels) payload.MinDiskSize = model.MinDiskSize payload.MinRam = model.MinRam payload.Name = model.Name payload.Protected = model.Protected + payload.Config = nil - if model.Config != nil { - payload.Config = &iaas.ImageConfig{ - BootMenu: model.Config.BootMenu, - CdromBus: iaas.NewNullableString(model.Config.CdromBus), - DiskBus: iaas.NewNullableString(model.Config.DiskBus), - NicModel: iaas.NewNullableString(model.Config.NicModel), - OperatingSystem: model.Config.OperatingSystem, - OperatingSystemDistro: iaas.NewNullableString(model.Config.OperatingSystemDistro), - OperatingSystemVersion: iaas.NewNullableString(model.Config.OperatingSystemVersion), - RescueBus: iaas.NewNullableString(model.Config.RescueBus), - RescueDevice: iaas.NewNullableString(model.Config.RescueDevice), - SecureBoot: model.Config.SecureBoot, - Uefi: model.Config.Uefi, - VideoModel: iaas.NewNullableString(model.Config.VideoModel), - VirtioScsi: model.Config.VirtioScsi, + if config := model.Config; config != nil { + payload.Config = &iaas.ImageConfig{} + if model.Config.BootMenu != nil { + payload.Config.BootMenu = model.Config.BootMenu + } + if model.Config.CdromBus != nil { + payload.Config.CdromBus = iaas.NewNullableString(model.Config.CdromBus) + } + if model.Config.DiskBus != nil { + payload.Config.DiskBus = iaas.NewNullableString(model.Config.DiskBus) + } + if model.Config.NicModel != nil { + payload.Config.NicModel = iaas.NewNullableString(model.Config.NicModel) + } + if model.Config.OperatingSystem != nil { + payload.Config.OperatingSystem = model.Config.OperatingSystem + } + if model.Config.OperatingSystemDistro != nil { + payload.Config.OperatingSystemDistro = iaas.NewNullableString(model.Config.OperatingSystemDistro) + } + if model.Config.OperatingSystemVersion != nil { + payload.Config.OperatingSystemVersion = iaas.NewNullableString(model.Config.OperatingSystemVersion) + } + if model.Config.RescueBus != nil { + payload.Config.RescueBus = iaas.NewNullableString(model.Config.RescueBus) + } + if model.Config.RescueDevice != nil { + payload.Config.RescueDevice = iaas.NewNullableString(model.Config.RescueDevice) + } + if model.Config.SecureBoot != nil { + payload.Config.SecureBoot = model.Config.SecureBoot + } + if model.Config.Uefi != nil { + payload.Config.Uefi = model.Config.Uefi + } + if model.Config.VideoModel != nil { + payload.Config.VideoModel = iaas.NewNullableString(model.Config.VideoModel) + } + if model.Config.VirtioScsi != nil { + payload.Config.VirtioScsi = model.Config.VirtioScsi } } diff --git a/internal/cmd/image/update/update_test.go b/internal/cmd/image/update/update_test.go index b08d3ceca..9246bd67b 100644 --- a/internal/cmd/image/update/update_test.go +++ b/internal/cmd/image/update/update_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -16,7 +18,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -49,7 +53,8 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, nameFlag: testName, diskFormatFlag: testDiskFormat, @@ -89,11 +94,15 @@ func parseLabels(labelstring string) map[string]string { func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, - Id: testImageId[0], - Name: &testName, - DiskFormat: &testDiskFormat, - Labels: utils.Ptr(parseLabels(testLabels)), + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Id: testImageId[0], + Name: &testName, + DiskFormat: &testDiskFormat, + Labels: utils.Ptr(parseLabels(testLabels)), Config: &imageConfig{ BootMenu: &testBootmenu, CdromBus: &testCdRomBus, @@ -154,7 +163,7 @@ func fixtureCreatePayload(mods ...func(payload *iaas.UpdateImagePayload)) (paylo } func fixtureRequest(mods ...func(*iaas.ApiUpdateImageRequest)) iaas.ApiUpdateImageRequest { - request := testClient.UpdateImage(testCtx, testProjectId, testImageId[0]) + request := testClient.UpdateImage(testCtx, testProjectId, testRegion, testImageId[0]) request = request.UpdateImagePayload(fixtureCreatePayload()) @@ -167,6 +176,7 @@ func fixtureRequest(mods ...func(*iaas.ApiUpdateImageRequest)) iaas.ApiUpdateIma func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string args []string isValid bool @@ -182,7 +192,7 @@ func TestParseInput(t *testing.T) { { description: "no values but valid image id", flagValues: map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, }, args: testImageId, isValid: false, @@ -194,7 +204,7 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), args: testImageId, isValid: false, @@ -202,7 +212,7 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), args: testImageId, isValid: false, @@ -210,7 +220,7 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), args: testImageId, isValid: false, @@ -297,12 +307,26 @@ func TestParseInput(t *testing.T) { model.Config.RescueDevice = nil }), }, + { + description: "update only name", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + nameFlag: "foo", + }, + args: testImageId, + isValid: true, + expectedModel: &inputModel{ + Name: utils.Ptr("foo"), + GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + Id: testImageId[0], + }, + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) if err := globalflags.Configure(cmd.Flags()); err != nil { t.Errorf("cannot configure global flags: %v", err) } @@ -372,7 +396,7 @@ func TestBuildRequest(t *testing.T) { model.Labels = nil }), expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) { - *request = request.UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { + *request = (*request).UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { payload.Labels = nil })) }), @@ -383,7 +407,7 @@ func TestBuildRequest(t *testing.T) { model.Name = utils.Ptr("something else") }), expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) { - *request = request.UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { + *request = (*request).UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { payload.Name = utils.Ptr("something else") })) }), @@ -394,11 +418,22 @@ func TestBuildRequest(t *testing.T) { model.Config.CdromBus = utils.Ptr("something else") }), expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) { - *request = request.UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { + *request = (*request).UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { payload.Config.CdromBus.Set(utils.Ptr("something else")) })) }), }, + { + description: "no config set", + model: fixtureInputModel(func(model *inputModel) { + model.Config = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) { + *request = (*request).UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) { + payload.Config = nil + })) + }), + }, } for _, tt := range tests { diff --git a/internal/cmd/key-pair/create/create.go b/internal/cmd/key-pair/create/create.go index 0c96810ba..5bb18ef2e 100644 --- a/internal/cmd/key-pair/create/create.go +++ b/internal/cmd/key-pair/create/create.go @@ -2,9 +2,10 @@ package create import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -12,7 +13,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -30,7 +30,7 @@ type inputModel struct { Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a key pair", @@ -54,25 +54,23 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit key-pair create --public-key `ssh-rsa xxx` --labels key=value,key1=value1", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := "Are your sure you want to create a key pair?" - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := "Are your sure you want to create a key pair?" + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -82,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create key pair: %w", err) } - return outputResult(p, model.GlobalFlagModel.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -98,7 +96,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) model := inputModel{ @@ -108,33 +106,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { PublicKey: flags.FlagToStringPointer(p, cmd, publicKeyFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string fo debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateKeyPairRequest { req := apiClient.CreateKeyPair(ctx) - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } - payload := iaas.CreateKeyPairPayload{ Name: model.Name, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), PublicKey: model.PublicKey, } @@ -146,24 +127,11 @@ func outputResult(p *print.Printer, outputFormat string, item *iaas.Keypair) err return fmt.Errorf("no key pair found") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(item, "", " ") - if err != nil { - return fmt.Errorf("marshal key pair: %w", err) - } - p.Outputln(string(details)) - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(item, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal key pair: %w", err) - } - p.Outputln(string(details)) - default: + return p.OutputResult(outputFormat, item, func() error { p.Outputf("Created key pair %q.\nkey pair Fingerprint: %q\n", utils.PtrString(item.Name), utils.PtrString(item.Fingerprint), ) - } - return nil + return nil + }) } diff --git a/internal/cmd/key-pair/create/create_test.go b/internal/cmd/key-pair/create/create_test.go index f912892d5..24418d845 100644 --- a/internal/cmd/key-pair/create/create_test.go +++ b/internal/cmd/key-pair/create/create_test.go @@ -5,8 +5,11 @@ import ( "os" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -77,6 +80,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateKeyPairPayload)) iaas.Creat func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -122,46 +126,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -224,7 +189,7 @@ func Test_outputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.item); (err != nil) != tt.wantErr { diff --git a/internal/cmd/key-pair/delete/delete.go b/internal/cmd/key-pair/delete/delete.go index ba9b5a9ce..17984e936 100644 --- a/internal/cmd/key-pair/delete/delete.go +++ b/internal/cmd/key-pair/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -23,7 +25,7 @@ type inputModel struct { KeyPairName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", keyPairNameArg), Short: "Deletes a key pair", @@ -37,23 +39,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete key pair %q?", model.KeyPairName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete key pair %q?", model.KeyPairName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete key pair: %w", err) } - p.Info("Deleted key pair %q\n", model.KeyPairName) + params.Printer.Info("Deleted key pair %q\n", model.KeyPairName) return nil }, @@ -81,15 +81,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu KeyPairName: keyPairName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/key-pair/delete/delete_test.go b/internal/cmd/key-pair/delete/delete_test.go index cfbcd29e8..47f75577a 100644 --- a/internal/cmd/key-pair/delete/delete_test.go +++ b/internal/cmd/key-pair/delete/delete_test.go @@ -4,9 +4,12 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -96,7 +99,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/key-pair/describe/describe.go b/internal/cmd/key-pair/describe/describe.go index f4ee01f94..40f450949 100644 --- a/internal/cmd/key-pair/describe/describe.go +++ b/internal/cmd/key-pair/describe/describe.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -35,7 +37,7 @@ type inputModel struct { PublicKey bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", keyPairNameArg), Short: "Describes a key pair", @@ -53,13 +55,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -72,9 +74,9 @@ func NewCmd(p *print.Printer) *cobra.Command { } if keypair := resp; keypair != nil { - return outputResult(p, model.OutputFormat, model.PublicKey, *keypair) + return outputResult(params.Printer, model.OutputFormat, model.PublicKey, *keypair) } - p.Outputln("No keypair found.") + params.Printer.Outputln("No keypair found.") return nil }, } @@ -97,15 +99,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu PublicKey: flags.FlagToBoolValue(p, cmd, publicKeyFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/key-pair/describe/describe_test.go b/internal/cmd/key-pair/describe/describe_test.go index 46374b3c8..b94ae4ece 100644 --- a/internal/cmd/key-pair/describe/describe_test.go +++ b/internal/cmd/key-pair/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -107,54 +110,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.argsValues) - 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(p, cmd, tt.argsValues) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argsValues, tt.flagValues, tt.isValid) }) } } @@ -209,7 +165,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.showOnlyPublicKey, tt.args.keyPair); (err != nil) != tt.wantErr { diff --git a/internal/cmd/key-pair/key-pair.go b/internal/cmd/key-pair/key-pair.go index 90bbd648b..0ac0c7015 100644 --- a/internal/cmd/key-pair/key-pair.go +++ b/internal/cmd/key-pair/key-pair.go @@ -3,16 +3,17 @@ package keypair import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/cmd/key-pair/create" "github.com/stackitcloud/stackit-cli/internal/cmd/key-pair/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/key-pair/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/key-pair/list" "github.com/stackitcloud/stackit-cli/internal/cmd/key-pair/update" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "key-pair", Short: "Provides functionality for SSH key pairs", @@ -20,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: cobra.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/key-pair/list/list.go b/internal/cmd/key-pair/list/list.go index 8685edb36..3820eb038 100644 --- a/internal/cmd/key-pair/list/list.go +++ b/internal/cmd/key-pair/list/list.go @@ -2,10 +2,11 @@ package list import ( "context" - "encoding/json" "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -17,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -33,7 +33,7 @@ type inputModel struct { LabelSelector *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all key pairs", @@ -57,15 +57,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit key-pair list --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -78,7 +78,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - p.Info("No key pairs found\n") + params.Printer.Info("No key pairs found\n") return nil } @@ -87,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -99,7 +99,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(labelSelectorFlag, "", "Filter by label") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) @@ -116,15 +116,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.InfoLevel, modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -137,22 +129,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat string, keyPairs []iaas.Keypair) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(keyPairs, "", " ") - if err != nil { - return fmt.Errorf("marshal key pairs: %w", err) - } - p.Outputln(string(details)) - - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(keyPairs, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal key pairs: %w", err) - } - p.Outputln(string(details)) - - default: + return p.OutputResult(outputFormat, keyPairs, func() error { table := tables.NewTable() table.SetHeader("KEY PAIR NAME", "LABELS", "FINGERPRINT", "CREATED AT", "UPDATED AT") @@ -176,6 +153,6 @@ func outputResult(p *print.Printer, outputFormat string, keyPairs []iaas.Keypair } p.Outputln(table.Render()) - } - return nil + return nil + }) } diff --git a/internal/cmd/key-pair/list/list_test.go b/internal/cmd/key-pair/list/list_test.go index 8fa0a948f..2ceb0d426 100644 --- a/internal/cmd/key-pair/list/list_test.go +++ b/internal/cmd/key-pair/list/list_test.go @@ -5,8 +5,11 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -58,6 +61,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListKeyPairsRequest)) iaas.Api func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -115,46 +119,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error parsing input: %v", err) - } - - if !tt.isValid { - t.Fatal("did not fail on invalid input") - } - diff := cmp.Diff(model, tt.expectedModel) - if diff != "" { - t.Fatalf("data does not match: %s", diff) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -212,7 +177,7 @@ func Test_outputResult(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) if err := outputResult(p, tt.args.outputFormat, tt.args.keyPairs); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) diff --git a/internal/cmd/key-pair/update/update.go b/internal/cmd/key-pair/update/update.go index 1a64875b5..14988bf28 100644 --- a/internal/cmd/key-pair/update/update.go +++ b/internal/cmd/key-pair/update/update.go @@ -2,9 +2,10 @@ package update import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -13,7 +14,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -29,7 +29,7 @@ type inputModel struct { KeyPairName *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", keyPairNameArg), Short: "Updates a key pair", @@ -43,20 +43,18 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model := parseInput(p, cmd, args) + model := parseInput(params.Printer, cmd, args) // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update key pair %q?", *model.KeyPairName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return fmt.Errorf("update key pair: %w", err) - } + prompt := fmt.Sprintf("Are you sure you want to update key pair %q?", *model.KeyPairName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return fmt.Errorf("update key pair: %w", err) } // Call API @@ -69,7 +67,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("response is nil") } - return outputResult(p, model, *resp) + return outputResult(params.Printer, model, *resp) }, } configureFlags(cmd) @@ -86,16 +84,8 @@ func configureFlags(cmd *cobra.Command) { func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateKeyPairRequest { req := apiClient.UpdateKeyPair(ctx, *model.KeyPairName) - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } payload := iaas.UpdateKeyPairPayload{ - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } return req.UpdateKeyPairPayload(payload) } @@ -110,38 +100,18 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) inputM KeyPairName: utils.Ptr(keyPairName), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return model } func outputResult(p *print.Printer, model inputModel, keyPair iaas.Keypair) error { var outputFormat string if model.GlobalFlagModel != nil { - outputFormat = model.GlobalFlagModel.OutputFormat + outputFormat = model.OutputFormat } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(keyPair, "", " ") - if err != nil { - return fmt.Errorf("marshal key pair: %w", err) - } - p.Outputln(string(details)) - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(keyPair, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal key pair: %w", err) - } - p.Outputln(string(details)) - default: + + return p.OutputResult(outputFormat, keyPair, func() error { p.Outputf("Updated labels of key pair %q\n", utils.PtrString(model.KeyPairName)) - } - return nil + return nil + }) } diff --git a/internal/cmd/key-pair/update/update_test.go b/internal/cmd/key-pair/update/update_test.go index 5743e2d60..7f24c935e 100644 --- a/internal/cmd/key-pair/update/update_test.go +++ b/internal/cmd/key-pair/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -109,7 +111,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -208,7 +210,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.keyPair); (err != nil) != tt.wantErr { diff --git a/internal/cmd/kms/key/create/create.go b/internal/cmd/kms/key/create/create.go new file mode 100644 index 000000000..740deffa9 --- /dev/null +++ b/internal/cmd/kms/key/create/create.go @@ -0,0 +1,202 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/stackit-sdk-go/services/kms/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + keyRingIdFlag = "keyring-id" + + algorithmFlag = "algorithm" + descriptionFlag = "description" + displayNameFlag = "name" + importOnlyFlag = "import-only" + purposeFlag = "purpose" + protectionFlag = "protection" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string + + Algorithm *string + Description *string + Name *string + ImportOnly bool // Default false + Purpose *string + Protection *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a KMS key", + Long: "Creates a KMS key.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a symmetric AES key (AES-256) with the name "symm-aes-gcm" under the key ring "my-keyring-id"`, + `$ stackit kms key create --keyring-id "my-keyring-id" --algorithm "aes_256_gcm" --name "symm-aes-gcm" --purpose "symmetric_encrypt_decrypt" --protection "software"`), + examples.NewExample( + `Create an asymmetric RSA encryption key (RSA-2048)`, + `$ stackit kms key create --keyring-id "my-keyring-id" --algorithm "rsa_2048_oaep_sha256" --name "prod-orders-rsa" --purpose "asymmetric_encrypt_decrypt" --protection "software"`), + examples.NewExample( + `Create a message authentication key (HMAC-SHA512)`, + `$ stackit kms key create --keyring-id "my-keyring-id" --algorithm "hmac_sha512" --name "api-mac-key" --purpose "message_authentication_code" --protection "software"`), + examples.NewExample( + `Create an ECDSA P-256 key for signing & verification`, + `$ stackit kms key create --keyring-id "my-keyring-id" --algorithm "ecdsa_p256_sha256" --name "signing-ecdsa-p256" --purpose "asymmetric_sign_verify" --protection "software"`), + examples.NewExample( + `Create an import-only key (versions must be imported)`, + `$ stackit kms key create --keyring-id "my-keyring-id" --algorithm "rsa_2048_oaep_sha256" --name "ext-managed-rsa" --purpose "asymmetric_encrypt_decrypt" --protection "software" --import-only`), + examples.NewExample( + `Create a key and print the result as YAML`, + `$ stackit kms key create --keyring-id "my-keyring-id" --algorithm "rsa_2048_oaep_sha256" --name "yaml-output-rsa" --purpose "asymmetric_encrypt_decrypt" --protection "software" --output yaml`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + err = params.Printer.PromptForConfirmation("Are you sure you want to create a KMS Key?") + if err != nil { + return err + } + + // Call API + req, _ := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create KMS key: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating key", func() error { + _, err = wait.CreateOrUpdateKeyWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, *resp.Id).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for KMS key creation: %w", err) + } + } + + return outputResult(params.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + Algorithm: flags.FlagToStringPointer(p, cmd, algorithmFlag), + Name: flags.FlagToStringPointer(p, cmd, displayNameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + ImportOnly: flags.FlagToBoolValue(p, cmd, importOnlyFlag), + Purpose: flags.FlagToStringPointer(p, cmd, purposeFlag), + Protection: flags.FlagToStringPointer(p, cmd, protectionFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +type kmsKeyClient interface { + CreateKey(ctx context.Context, projectId string, regionId string, keyRingId string) kms.ApiCreateKeyRequest +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient kmsKeyClient) (kms.ApiCreateKeyRequest, error) { + req := apiClient.CreateKey(ctx, model.ProjectId, model.Region, model.KeyRingId) + + req = req.CreateKeyPayload(kms.CreateKeyPayload{ + DisplayName: model.Name, + Description: model.Description, + Algorithm: kms.CreateKeyPayloadGetAlgorithmAttributeType(model.Algorithm), + Purpose: kms.CreateKeyPayloadGetPurposeAttributeType(model.Purpose), + ImportOnly: &model.ImportOnly, + Protection: kms.CreateKeyPayloadGetProtectionAttributeType(model.Protection), + }) + return req, nil +} + +func outputResult(p *print.Printer, model *inputModel, resp *kms.Key) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s the KMS key %q. Key ID: %s\n", operationState, utils.PtrString(resp.DisplayName), utils.PtrString(resp.Id)) + return nil + }) +} + +func configureFlags(cmd *cobra.Command) { + // Algorithm + var algorithmFlagOptions []string + for _, val := range kms.AllowedAlgorithmEnumValues { + algorithmFlagOptions = append(algorithmFlagOptions, string(val)) + } + cmd.Flags().Var(flags.EnumFlag(false, "", algorithmFlagOptions...), algorithmFlag, fmt.Sprintf("En-/Decryption / signing algorithm. Possible values: %q", algorithmFlagOptions)) + + // Purpose + var purposeFlagOptions []string + for _, val := range kms.AllowedPurposeEnumValues { + purposeFlagOptions = append(purposeFlagOptions, string(val)) + } + cmd.Flags().Var(flags.EnumFlag(false, "", purposeFlagOptions...), purposeFlag, fmt.Sprintf("Purpose of the key. Possible values: %q", purposeFlagOptions)) + + // Protection + var protectionFlagOptions []string + for _, val := range kms.AllowedProtectionEnumValues { + protectionFlagOptions = append(protectionFlagOptions, string(val)) + } + cmd.Flags().Var(flags.EnumFlag(false, "", protectionFlagOptions...), protectionFlag, fmt.Sprintf("The underlying system that is responsible for protecting the key material. Possible values: %q", purposeFlagOptions)) + + // All further non Enum Flags + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring") + cmd.Flags().String(displayNameFlag, "", "The display name to distinguish multiple keys") + cmd.Flags().String(descriptionFlag, "", "Optional description of the key") + cmd.Flags().Bool(importOnlyFlag, false, "States whether versions can be created or only imported") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag, algorithmFlag, purposeFlag, displayNameFlag, protectionFlag) + cobra.CheckErr(err) +} diff --git a/internal/cmd/kms/key/create/create_test.go b/internal/cmd/kms/key/create/create_test.go new file mode 100644 index 000000000..ce125a298 --- /dev/null +++ b/internal/cmd/kms/key/create/create_test.go @@ -0,0 +1,329 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testRegion = "eu01" + testAlgorithm = "rsa_2048_oaep_sha256" + testDisplayName = "my-key" + testPurpose = "asymmetric_encrypt_decrypt" + testDescription = "my key description" + testImportOnly = "true" + testProtection = "software" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + algorithmFlag: testAlgorithm, + displayNameFlag: testDisplayName, + purposeFlag: testPurpose, + descriptionFlag: testDescription, + importOnlyFlag: testImportOnly, + protectionFlag: testProtection, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + Algorithm: utils.Ptr(testAlgorithm), + Name: utils.Ptr(testDisplayName), + Purpose: utils.Ptr(testPurpose), + Description: utils.Ptr(testDescription), + ImportOnly: true, // Watch out: ImportOnly is not testImportOnly! + Protection: utils.Ptr(testProtection), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiCreateKeyRequest)) kms.ApiCreateKeyRequest { + request := testClient.CreateKey(testCtx, testProjectId, testRegion, testKeyRingId) + request = request.CreateKeyPayload(kms.CreateKeyPayload{ + Algorithm: kms.CreateKeyPayloadGetAlgorithmAttributeType(utils.Ptr(testAlgorithm)), + DisplayName: utils.Ptr(testDisplayName), + Purpose: kms.CreateKeyPayloadGetPurposeAttributeType(utils.Ptr(testPurpose)), + Description: utils.Ptr(testDescription), + ImportOnly: utils.Ptr(true), + Protection: kms.CreateKeyPayloadGetProtectionAttributeType(utils.Ptr(testProtection)), + }) + + 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: "optional flags omitted", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + delete(flagValues, importOnlyFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.ImportOnly = false + }), + }, + { + description: "no values provided", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "algorithm missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, algorithmFlag) + }), + isValid: false, + }, + { + description: "protection missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, protectionFlag) + }), + isValid: false, + }, + { + description: "name missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "purpose missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, purposeFlag) + }), + 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) + } + + p := print.NewPrinter() + model, err := parseInput(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiCreateKeyRequest + }{ + { + description: "base case", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optional values", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.ImportOnly = false + }), + expectedRequest: fixtureRequest().CreateKeyPayload(kms.CreateKeyPayload{ + Algorithm: kms.CreateKeyPayloadGetAlgorithmAttributeType(utils.Ptr(testAlgorithm)), + DisplayName: utils.Ptr(testDisplayName), + Purpose: kms.CreateKeyPayloadGetPurposeAttributeType(utils.Ptr(testPurpose)), + Description: nil, + ImportOnly: utils.Ptr(false), + Protection: kms.CreateKeyPayloadGetProtectionAttributeType(utils.Ptr(testProtection)), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + key *kms.Key + wantErr bool + }{ + { + description: "nil response", + key: nil, + wantErr: true, + }, + { + description: "default output", + key: &kms.Key{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + wantErr: false, + }, + { + description: "json output", + key: &kms.Key{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + wantErr: false, + }, + { + description: "yaml output", + key: &kms.Key{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.YAMLOutputFormat}}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.model, tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/key/delete/delete.go b/internal/cmd/kms/key/delete/delete.go new file mode 100644 index 000000000..63bc78763 --- /dev/null +++ b/internal/cmd/kms/key/delete/delete.go @@ -0,0 +1,134 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + kmsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" +) + +const ( + keyIdArg = "KEY_ID" + + keyRingIdFlag = "keyring-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyId string + KeyRingId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", keyIdArg), + Short: "Deletes a KMS key", + Long: "Deletes a KMS key inside a specific key ring.", + Args: args.SingleArg(keyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a KMS key "MY_KEY_ID" inside the key ring "my-keyring-id"`, + `$ stackit kms key delete "MY_KEY_ID" --keyring-id "my-keyring-id"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + keyName, err := kmsUtils.GetKeyName(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key name: %v", err) + keyName = model.KeyId + } + + prompt := fmt.Sprintf("Are you sure you want to delete key %q? (This cannot be undone)", keyName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete KMS key: %w", err) + } + + // Don't wait for a month until the deletion was performed. + // Just print the deletion date. + resp, err := apiClient.GetKeyExecute(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key: %v", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + keyId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + KeyId: keyId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiDeleteKeyRequest { + req := apiClient.DeleteKey(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + return req +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring where the key is stored") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat string, resp *kms.Key) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Deletion of KMS key %s scheduled successfully for the deletion date: %s\n", utils.PtrString(resp.DisplayName), utils.PtrString(resp.DeletionDate)) + return nil + }) +} diff --git a/internal/cmd/kms/key/delete/delete_test.go b/internal/cmd/kms/key/delete/delete_test.go new file mode 100644 index 000000000..8f387a048 --- /dev/null +++ b/internal/cmd/kms/key/delete/delete_test.go @@ -0,0 +1,294 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiDeleteKeyRequest)) kms.ApiDeleteKeyRequest { + request := testClient.DeleteKey(testCtx, testProjectId, testRegion, testKeyRingId, 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (keyId)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + 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) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiDeleteKeyRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + wantErr bool + outputFormat string + resp *kms.Key + }{ + { + description: "nil response", + resp: nil, + wantErr: true, + }, + { + description: "default output", + resp: &kms.Key{}, + wantErr: false, + }, + { + description: "json output", + outputFormat: print.JSONOutputFormat, + resp: &kms.Key{}, + wantErr: false, + }, + { + description: "yaml output", + outputFormat: print.YAMLOutputFormat, + resp: &kms.Key{}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/key/describe/describe.go b/internal/cmd/kms/key/describe/describe.go new file mode 100644 index 000000000..1ddec4c7a --- /dev/null +++ b/internal/cmd/kms/key/describe/describe.go @@ -0,0 +1,133 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + argKeyID = "KEY_ID" + flagKeyRingID = "keyring-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyID string + KeyRingID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", argKeyID), + Short: "Describe a KMS key", + Long: "Describe a KMS key", + Args: args.SingleArg(argKeyID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a KMS key with ID xxx of keyring yyy`, + `$ stackit kms key describe xxx --keyring-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get key: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), flagKeyRingID, "Key Ring ID") + err := flags.MarkFlagsRequired(cmd, flagKeyRingID) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + model := &inputModel{ + GlobalFlagModel: globalFlags, + KeyID: inputArgs[0], + KeyRingID: flags.FlagToStringValue(p, cmd, flagKeyRingID), + } + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiGetKeyRequest { + return apiClient.GetKey(ctx, model.ProjectId, model.Region, model.KeyRingID, model.KeyID) +} + +func outputResult(p *print.Printer, outputFormat string, key *kms.Key) error { + if key == nil { + return fmt.Errorf("key response is empty") + } + return p.OutputResult(outputFormat, key, func() error { + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(key.Id)) + table.AddSeparator() + table.AddRow("DISPLAY NAME", utils.PtrString(key.DisplayName)) + table.AddSeparator() + table.AddRow("CREATED AT", utils.PtrString(key.CreatedAt)) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(key.State)) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(key.Description)) + table.AddSeparator() + table.AddRow("ACCESS SCOPE", utils.PtrString(key.AccessScope)) + table.AddSeparator() + table.AddRow("ALGORITHM", utils.PtrString(key.Algorithm)) + table.AddSeparator() + table.AddRow("DELETION DATE", utils.PtrString(key.DeletionDate)) + table.AddSeparator() + table.AddRow("IMPORT ONLY", utils.PtrString(key.ImportOnly)) + table.AddSeparator() + table.AddRow("KEYRING ID", utils.PtrString(key.KeyRingId)) + table.AddSeparator() + table.AddRow("PROTECTION", utils.PtrString(key.Protection)) + table.AddSeparator() + table.AddRow("PURPOSE", utils.PtrString(key.Purpose)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/kms/key/describe/describe_test.go b/internal/cmd/kms/key/describe/describe_test.go new file mode 100644 index 000000000..62d8b45ab --- /dev/null +++ b/internal/cmd/kms/key/describe/describe_test.go @@ -0,0 +1,224 @@ +package describe + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &kms.APIClient{} +var testProjectId = uuid.NewString() +var testKeyRingID = uuid.NewString() +var testKeyID = uuid.NewString() +var testTime = time.Time{} + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + flagKeyRingID: testKeyRingID, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyID: testKeyID, + KeyRingID: testKeyRingID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: []string{testKeyID}, + 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: []string{testKeyID}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "invalid key id", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing key ring id", + argValues: []string{testKeyID}, + flagValues: fixtureFlagValues(func(m map[string]string) { delete(m, flagKeyRingID) }), + isValid: false, + }, + { + description: "invalid key ring id", + argValues: []string{testKeyID}, + flagValues: fixtureFlagValues(func(m map[string]string) { + m[flagKeyRingID] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing project id", + argValues: []string{testKeyID}, + flagValues: fixtureFlagValues(func(m map[string]string) { delete(m, globalflags.ProjectIdFlag) }), + isValid: false, + }, + { + description: "invalid project id", + argValues: []string{testKeyID}, + flagValues: fixtureFlagValues(func(m map[string]string) { m[globalflags.ProjectIdFlag] = "invalid-uuid" }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + got := buildRequest(testCtx, fixtureInputModel(), testClient) + want := testClient.GetKey(testCtx, testProjectId, testRegion, testKeyRingID, testKeyID) + diff := cmp.Diff(got, want, + cmp.AllowUnexported(want), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("buildRequest() mismatch (-want +got):\n%s", diff) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFmt string + keyRing *kms.Key + wantErr bool + expected string + }{ + { + description: "empty", + outputFmt: "table", + wantErr: true, + }, + { + description: "table format", + outputFmt: "table", + keyRing: &kms.Key{ + AccessScope: utils.Ptr(kms.ACCESSSCOPE_PUBLIC), + Algorithm: utils.Ptr(kms.ALGORITHM_AES_256_GCM), + CreatedAt: utils.Ptr(testTime), + DeletionDate: nil, + Description: utils.Ptr("very secure and secret key"), + DisplayName: utils.Ptr("Test Key"), + Id: utils.Ptr(testKeyID), + ImportOnly: utils.Ptr(true), + KeyRingId: utils.Ptr(testKeyRingID), + Protection: utils.Ptr(kms.PROTECTION_SOFTWARE), + Purpose: utils.Ptr(kms.PURPOSE_SYMMETRIC_ENCRYPT_DECRYPT), + State: utils.Ptr(kms.KEYSTATE_ACTIVE), + }, + expected: fmt.Sprintf(` + ID │ %-37s +───────────────┼────────────────────────────────────── + DISPLAY NAME │ Test Key +───────────────┼────────────────────────────────────── + CREATED AT │ %-37s +───────────────┼────────────────────────────────────── + STATE │ active +───────────────┼────────────────────────────────────── + DESCRIPTION │ very secure and secret key +───────────────┼────────────────────────────────────── + ACCESS SCOPE │ PUBLIC +───────────────┼────────────────────────────────────── + ALGORITHM │ aes_256_gcm +───────────────┼────────────────────────────────────── + DELETION DATE │ +───────────────┼────────────────────────────────────── + IMPORT ONLY │ true +───────────────┼────────────────────────────────────── + KEYRING ID │ %-37s +───────────────┼────────────────────────────────────── + PROTECTION │ software +───────────────┼────────────────────────────────────── + PURPOSE │ symmetric_encrypt_decrypt + +`, + testKeyID, + testTime, + testKeyRingID, + ), + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var buf bytes.Buffer + p.Cmd.SetOut(&buf) + if err := outputResult(p, tt.outputFmt, tt.keyRing); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + diff := cmp.Diff(buf.String(), tt.expected) + if diff != "" { + t.Fatalf("outputResult() output mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/kms/key/importKey/importKey.go b/internal/cmd/kms/key/importKey/importKey.go new file mode 100644 index 000000000..8e83b9681 --- /dev/null +++ b/internal/cmd/kms/key/importKey/importKey.go @@ -0,0 +1,164 @@ +package importKey + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + kmsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + keyIdArg = "KEY_ID" + + keyRingIdFlag = "keyring-id" + wrappedKeyFlag = "wrapped-key" + wrappingKeyIdFlag = "wrapping-key-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string + KeyId string + WrappedKey *string + WrappingKeyId *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("import %s", keyIdArg), + Short: "Import a KMS key", + Long: "After encrypting the secret with the wrapping key’s public key and Base64-encoding it, import it as a new version of the specified KMS key.", + Args: args.SingleArg(keyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Import a new version for the given KMS key "MY_KEY_ID" from literal value`, + `$ stackit kms key import "MY_KEY_ID" --keyring-id "my-keyring-id" --wrapped-key "BASE64_VALUE" --wrapping-key-id "MY_WRAPPING_KEY_ID"`), + examples.NewExample( + `Import from a file`, + `$ stackit kms key import "MY_KEY_ID" --keyring-id "my-keyring-id" --wrapped-key "@path/to/wrapped.key.b64" --wrapping-key-id "MY_WRAPPING_KEY_ID"`, + ), + ), + + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + keyName, err := kmsUtils.GetKeyName(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key name: %v", err) + keyName = model.KeyId + } + keyRingName, err := kmsUtils.GetKeyRingName(ctx, apiClient, model.ProjectId, model.KeyRingId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key ring name: %v", err) + keyRingName = model.KeyRingId + } + + prompt := fmt.Sprintf("Are you sure you want to import a new version for the KMS Key %q inside the key ring %q?", keyName, keyRingName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req, _ := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("import KMS key: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, keyRingName, keyName, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + keyId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // WrappedKey needs to be base64 encoded + var wrappedKey = flags.FlagToStringPointer(p, cmd, wrappedKeyFlag) + _, err := base64.StdEncoding.DecodeString(*wrappedKey) + if err != nil || *wrappedKey == "" { + return nil, &cliErr.FlagValidationError{ + Flag: wrappedKeyFlag, + Details: "The 'wrappedKey' argument is required and needs to be base64 encoded (whether provided inline or via file).", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyId: keyId, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + WrappedKey: wrappedKey, + WrappingKeyId: flags.FlagToStringPointer(p, cmd, wrappingKeyIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +type kmsKeyClient interface { + ImportKey(ctx context.Context, projectId string, regionId string, keyRingId string, keyId string) kms.ApiImportKeyRequest +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient kmsKeyClient) (kms.ApiImportKeyRequest, error) { + req := apiClient.ImportKey(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + + req = req.ImportKeyPayload(kms.ImportKeyPayload{ + WrappedKey: model.WrappedKey, + WrappingKeyId: model.WrappingKeyId, + }) + return req, nil +} + +func outputResult(p *print.Printer, outputFormat, keyRingName, keyName string, resp *kms.Version) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Imported a new version for the key %q inside the key ring %q\n", keyName, keyRingName) + return nil + }) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring") + cmd.Flags().Var(flags.ReadFromFileFlag(), wrappedKeyFlag, "The wrapped key material to be imported. Base64-encoded. Pass the value directly or a file path (e.g. @path/to/wrapped.key.b64)") + cmd.Flags().Var(flags.UUIDFlag(), wrappingKeyIdFlag, "The unique id of the wrapping key the key material has been wrapped with") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag, wrappedKeyFlag, wrappingKeyIdFlag) + cobra.CheckErr(err) +} diff --git a/internal/cmd/kms/key/importKey/importKey_test.go b/internal/cmd/kms/key/importKey/importKey_test.go new file mode 100644 index 000000000..6e19027c3 --- /dev/null +++ b/internal/cmd/kms/key/importKey/importKey_test.go @@ -0,0 +1,364 @@ +package importKey + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() + testWrappingKeyId = uuid.NewString() + testWrappedKey = "SnVzdCBzYXlpbmcgaGV5Oyk=" +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + wrappedKeyFlag: testWrappedKey, + wrappingKeyIdFlag: testWrappingKeyId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + WrappedKey: &testWrappedKey, + WrappingKeyId: &testWrappingKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiImportKeyRequest)) kms.ApiImportKeyRequest { + request := testClient.ImportKey(testCtx, testProjectId, testRegion, testKeyRingId, testKeyId) + request = request.ImportKeyPayload(kms.ImportKeyPayload{ + WrappedKey: &testWrappedKey, + WrappingKeyId: &testWrappingKeyId, + }) + + 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 (keyId)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing (required)", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "key id invalid 2", + argValues: []string{"invalid-key"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "wrapping key id missing (required)", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, wrappingKeyIdFlag) + }), + isValid: false, + }, + { + description: "wrapping key id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[wrappingKeyIdFlag] = "" + }), + isValid: false, + }, + { + description: "wrapping key id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[wrappingKeyIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "wrapped key missing (required)", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, wrappedKeyFlag) + }), + isValid: false, + }, + { + description: "wrapped key invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[wrappedKeyFlag] = "" + }), + isValid: false, + }, + { + description: "wrapped key invalid 2 - not base64", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[wrappedKeyFlag] = "Not Base 64" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiImportKeyRequest + }{ + { + description: "base case", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + version *kms.Version + outputFormat string + keyRingName string + keyName string + wantErr bool + }{ + { + description: "nil response", + version: nil, + wantErr: true, + }, + { + description: "default output", + version: &kms.Version{}, + keyRingName: "my-key-ring", + keyName: "my-key", + wantErr: false, + }, + { + description: "json output", + version: &kms.Version{}, + outputFormat: print.JSONOutputFormat, + keyRingName: "my-key-ring", + keyName: "my-key", + wantErr: false, + }, + { + description: "yaml output", + version: &kms.Version{}, + outputFormat: print.YAMLOutputFormat, + keyRingName: "my-key-ring", + keyName: "my-key", + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.keyRingName, tt.keyName, tt.version) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/key/key.go b/internal/cmd/kms/key/key.go new file mode 100644 index 000000000..4ebf30501 --- /dev/null +++ b/internal/cmd/kms/key/key.go @@ -0,0 +1,38 @@ +package key + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/key/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/key/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/key/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/key/importKey" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/key/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/key/restore" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/key/rotate" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "key", + Short: "Manage KMS keys", + Long: "Provides functionality for key operations inside the KMS", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(importKey.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(restore.NewCmd(params)) + cmd.AddCommand(rotate.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) +} diff --git a/internal/cmd/kms/key/list/list.go b/internal/cmd/kms/key/list/list.go new file mode 100644 index 000000000..244fb40a0 --- /dev/null +++ b/internal/cmd/kms/key/list/list.go @@ -0,0 +1,133 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + keyRingIdFlag = "keyring-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all KMS keys", + Long: "List all KMS keys inside a key ring.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all KMS keys for the key ring "my-keyring-id"`, + `$ stackit kms key list --keyring-id "my-keyring-id"`), + examples.NewExample( + `List all KMS keys in JSON format`, + `$ stackit kms key list --keyring-id "my-keyring-id" --output-format json`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get KMS Keys: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.ProjectId, model.KeyRingId, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiListKeysRequest { + req := apiClient.ListKeys(ctx, model.ProjectId, model.Region, model.KeyRingId) + return req +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring where the key is stored") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat, projectId, keyRingId string, resp *kms.KeyList) error { + if resp == nil || resp.Keys == nil { + return fmt.Errorf("response was nil / empty") + } + + keys := *resp.Keys + + return p.OutputResult(outputFormat, keys, func() error { + if len(keys) == 0 { + p.Outputf("No keys found for project %q under the key ring %q\n", projectId, keyRingId) + return nil + } + table := tables.NewTable() + table.SetHeader("ID", "NAME", "SCOPE", "ALGORITHM", "DELETION DATE", "STATUS") + + for _, key := range keys { + table.AddRow( + utils.PtrString(key.Id), + utils.PtrString(key.DisplayName), + utils.PtrString(key.Purpose), + utils.PtrString(key.Algorithm), + utils.PtrString(key.DeletionDate), + utils.PtrString(key.State), + ) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/kms/key/list/list_test.go b/internal/cmd/kms/key/list/list_test.go new file mode 100644 index 000000000..9f7b83b18 --- /dev/null +++ b/internal/cmd/kms/key/list/list_test.go @@ -0,0 +1,260 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiListKeysRequest)) kms.ApiListKeysRequest { + request := testClient.ListKeys(testCtx, testProjectId, testRegion, testKeyRingId) + 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: "missing keyRingId", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "invalid keyRingId 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid keyRingId 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "Not a valid uuid" + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) + } + + 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) + } + + p := print.NewPrinter() + model, err := parseInput(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiListKeysRequest + }{ + { + description: "base case", + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + resp *kms.KeyList + projectId string + keyRingId string + outputFormat string + wantErr bool + }{ + { + description: "nil response", + resp: nil, + projectId: uuid.NewString(), + keyRingId: uuid.NewString(), + wantErr: true, + }, + { + description: "empty response", + resp: &kms.KeyList{}, + projectId: uuid.NewString(), + keyRingId: uuid.NewString(), + wantErr: true, + }, + { + description: "default output", + resp: &kms.KeyList{Keys: &[]kms.Key{}}, + projectId: uuid.NewString(), + keyRingId: uuid.NewString(), + wantErr: false, + }, + { + description: "json output", + resp: &kms.KeyList{Keys: &[]kms.Key{}}, + projectId: uuid.NewString(), + keyRingId: uuid.NewString(), + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + description: "yaml output", + resp: &kms.KeyList{Keys: &[]kms.Key{}}, + projectId: uuid.NewString(), + keyRingId: uuid.NewString(), + outputFormat: print.YAMLOutputFormat, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.projectId, tt.keyRingId, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/key/restore/restore.go b/internal/cmd/kms/key/restore/restore.go new file mode 100644 index 000000000..e6825cb92 --- /dev/null +++ b/internal/cmd/kms/key/restore/restore.go @@ -0,0 +1,133 @@ +package restore + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + kmsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" +) + +const ( + keyIdArg = "KEY_ID" + + keyRingIdFlag = "keyring-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyId string + KeyRingId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("restore %s", keyIdArg), + Short: "Restore a key", + Long: "Restores the given key from deletion.", + Args: args.SingleArg(keyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Restore a KMS key "MY_KEY_ID" inside the key ring "my-keyring-id" that was scheduled for deletion.`, + `$ stackit kms key restore "MY_KEY_ID" --keyring-id "my-keyring-id"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + keyName, err := kmsUtils.GetKeyName(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key name: %v", err) + keyName = model.KeyId + } + + prompt := fmt.Sprintf("Are you sure you want to restore key %q? (This cannot be undone)", keyName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("restore KMS key: %w", err) + } + + // Grab the key after the restore was applied to display the new state to the user. + resp, err := apiClient.GetKeyExecute(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key: %v", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + keyId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + KeyId: keyId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiRestoreKeyRequest { + req := apiClient.RestoreKey(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + return req +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring where the key is stored") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat string, resp *kms.Key) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Successfully restored KMS key %q\n", utils.PtrString(resp.DisplayName)) + return nil + }) +} diff --git a/internal/cmd/kms/key/restore/restore_test.go b/internal/cmd/kms/key/restore/restore_test.go new file mode 100644 index 000000000..d15db6938 --- /dev/null +++ b/internal/cmd/kms/key/restore/restore_test.go @@ -0,0 +1,294 @@ +package restore + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiRestoreKeyRequest)) kms.ApiRestoreKeyRequest { + request := testClient.RestoreKey(testCtx, testProjectId, testRegion, testKeyRingId, 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (keyId)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + 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) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiRestoreKeyRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + wantErr bool + outputFormat string + resp *kms.Key + }{ + { + description: "nil response", + resp: nil, + wantErr: true, + }, + { + description: "default output", + resp: &kms.Key{}, + wantErr: false, + }, + { + description: "json output", + outputFormat: print.JSONOutputFormat, + resp: &kms.Key{}, + wantErr: false, + }, + { + description: "yaml output", + outputFormat: print.YAMLOutputFormat, + resp: &kms.Key{}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/key/rotate/rotate.go b/internal/cmd/kms/key/rotate/rotate.go new file mode 100644 index 000000000..1f2332572 --- /dev/null +++ b/internal/cmd/kms/key/rotate/rotate.go @@ -0,0 +1,127 @@ +package rotate + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + kmsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + keyIdArg = "KEY_ID" + + keyRingIdFlag = "keyring-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyId string + KeyRingId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("rotate %s", keyIdArg), + Short: "Rotate a key", + Long: "Rotates the given key.", + Args: args.SingleArg(keyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Rotate a KMS key "MY_KEY_ID" and increase its version inside the key ring "my-keyring-id".`, + `$ stackit kms key rotate "MY_KEY_ID" --keyring-id "my-keyring-id"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + keyName, err := kmsUtils.GetKeyName(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key name: %v", err) + keyName = model.KeyId + } + + prompt := fmt.Sprintf("Are you sure you want to rotate the key %q? (this cannot be undone)", keyName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("rotate KMS key: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + keyId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + KeyId: keyId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiRotateKeyRequest { + req := apiClient.RotateKey(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) + return req +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring where the key is stored") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat string, resp *kms.Version) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Rotated key %s\n", utils.PtrString(resp.KeyId)) + return nil + }) +} diff --git a/internal/cmd/kms/key/rotate/rotate_test.go b/internal/cmd/kms/key/rotate/rotate_test.go new file mode 100644 index 000000000..6211e8fa5 --- /dev/null +++ b/internal/cmd/kms/key/rotate/rotate_test.go @@ -0,0 +1,294 @@ +package rotate + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiRotateKeyRequest)) kms.ApiRotateKeyRequest { + request := testClient.RotateKey(testCtx, testProjectId, testRegion, testKeyRingId, 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (keyId)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + 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) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiRotateKeyRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + resp *kms.Version + outputFormat string + wantErr bool + }{ + { + description: "nil response", + resp: nil, + wantErr: true, + }, + { + description: "default output", + resp: &kms.Version{}, + wantErr: false, + }, + { + description: "json output", + resp: &kms.Version{}, + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + description: "yaml output", + resp: &kms.Version{}, + outputFormat: print.YAMLOutputFormat, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/keyring/create/create.go b/internal/cmd/kms/keyring/create/create.go new file mode 100644 index 000000000..2d031e85e --- /dev/null +++ b/internal/cmd/kms/keyring/create/create.go @@ -0,0 +1,166 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/stackit-sdk-go/services/kms/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + keyRingNameFlag = "name" + descriptionFlag = "description" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyringName string + Description string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a KMS key ring", + Long: "Creates a KMS key ring.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a KMS key ring with name "my-keyring"`, + "$ stackit kms keyring create --name my-keyring"), + examples.NewExample( + `Create a KMS key ring with a description`, + "$ stackit kms keyring create --name my-keyring --description my-description"), + examples.NewExample( + `Create a KMS key ring and print the result as YAML`, + "$ stackit kms keyring create --name my-keyring -o yaml"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + err = params.Printer.PromptForConfirmation("Are you sure you want to create a KMS key ring?") + if err != nil { + return err + } + + // Call API + req, _ := buildRequest(ctx, model, apiClient) + + keyRing, err := req.Execute() + if err != nil { + return fmt.Errorf("create KMS key ring: %w", err) + } + + // Prevent potential nil pointer dereference + if keyRing == nil || keyRing.Id == nil { + return fmt.Errorf("API call succeeded but returned an invalid response (missing key ring ID)") + } + + keyRingId := *keyRing.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating key ring", func() error { + _, err = wait.CreateKeyRingWaitHandler(ctx, apiClient, model.ProjectId, model.Region, keyRingId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for KMS key ring creation: %w", err) + } + } + + return outputResult(params.Printer, model, keyRing) + }, + } + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + keyringName := flags.FlagToStringValue(p, cmd, keyRingNameFlag) + + if keyringName == "" { + return nil, &cliErr.DSAInputPlanError{ + Cmd: cmd, + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyringName: keyringName, + Description: flags.FlagToStringValue(p, cmd, descriptionFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +type kmsKeyringClient interface { + CreateKeyRing(ctx context.Context, projectId string, regionId string) kms.ApiCreateKeyRingRequest +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient kmsKeyringClient) (kms.ApiCreateKeyRingRequest, error) { + req := apiClient.CreateKeyRing(ctx, model.ProjectId, model.Region) + + req = req.CreateKeyRingPayload(kms.CreateKeyRingPayload{ + DisplayName: &model.KeyringName, + + // Description should be empty by default and only be overwritten with the descriptionFlag if it was passed. + Description: &model.Description, + }) + return req, nil +} + +func outputResult(p *print.Printer, model *inputModel, resp *kms.KeyRing) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s key ring. KMS key ring ID: %s\n", operationState, utils.PtrString(resp.Id)) + return nil + }) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(keyRingNameFlag, "", "Name of the KMS key ring") + cmd.Flags().String(descriptionFlag, "", "Optional description of the key ring") + + err := flags.MarkFlagsRequired(cmd, keyRingNameFlag) + cobra.CheckErr(err) +} diff --git a/internal/cmd/kms/keyring/create/create_test.go b/internal/cmd/kms/keyring/create/create_test.go new file mode 100644 index 000000000..ae7111505 --- /dev/null +++ b/internal/cmd/kms/keyring/create/create_test.go @@ -0,0 +1,251 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testRegion = "eu01" + testKeyRingName = "my-key-ring" + testDescription = "my-description" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingNameFlag: testKeyRingName, + descriptionFlag: testDescription, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyringName: testKeyRingName, + Description: testDescription, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiCreateKeyRingRequest)) kms.ApiCreateKeyRingRequest { + request := testClient.CreateKeyRing(testCtx, testProjectId, testRegion) + request = request.CreateKeyRingPayload(kms.CreateKeyRingPayload{ + DisplayName: utils.Ptr(testKeyRingName), + Description: utils.Ptr(testDescription), + }) + + 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: "optional flags omitted", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = "" + }), + }, + { + description: "no values provided", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) + } + + 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) + } + + p := print.NewPrinter() + model, err := parseInput(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiCreateKeyRingRequest + }{ + { + description: "base case", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + model *inputModel + description string + keyRing *kms.KeyRing + wantErr bool + }{ + { + description: "nil response", + keyRing: nil, + wantErr: true, + }, + { + description: "default output", + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + keyRing: &kms.KeyRing{}, + wantErr: false, + }, + { + description: "json output", + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + keyRing: &kms.KeyRing{}, + wantErr: false, + }, + { + description: "yaml output", + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.YAMLOutputFormat}}, + keyRing: &kms.KeyRing{}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.model, tt.keyRing) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/keyring/delete/delete.go b/internal/cmd/kms/keyring/delete/delete.go new file mode 100644 index 000000000..03393c270 --- /dev/null +++ b/internal/cmd/kms/keyring/delete/delete.go @@ -0,0 +1,106 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + kmsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + keyRingIdArg = "KEYRING-ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", keyRingIdArg), + Short: "Deletes a KMS key ring", + Long: "Deletes a KMS key ring.", + Args: args.SingleArg(keyRingIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a KMS key ring with ID "MY_KEYRING_ID"`, + `$ stackit kms keyring delete "MY_KEYRING_ID"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + keyRingLabel, err := kmsUtils.GetKeyRingName(ctx, apiClient, model.ProjectId, model.KeyRingId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key ring name: %v", err) + keyRingLabel = model.KeyRingId + } + + prompt := fmt.Sprintf("Are you sure you want to delete key ring %q? (this cannot be undone)", keyRingLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete KMS key ring: %w", err) + } + + // No async wait required; key ring deletion is synchronous. + + // Don't output anything. It's a deletion. + params.Printer.Info("Deleted the key ring %q\n", keyRingLabel) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + keyRingId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: keyRingId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiDeleteKeyRingRequest { + req := apiClient.DeleteKeyRing(ctx, model.ProjectId, model.Region, model.KeyRingId) + return req +} diff --git a/internal/cmd/kms/keyring/delete/delete_test.go b/internal/cmd/kms/keyring/delete/delete_test.go new file mode 100644 index 000000000..0d950b0d8 --- /dev/null +++ b/internal/cmd/kms/keyring/delete/delete_test.go @@ -0,0 +1,214 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyRingId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiDeleteKeyRingRequest)) kms.ApiDeleteKeyRingRequest { + request := testClient.DeleteKeyRing(testCtx, testProjectId, testRegion, testKeyRingId) + 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 (keyRingId)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid keyRingId", + argValues: fixtureArgValues(func(argValues []string) { + argValues[0] = "Not an uuid" + }), + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiDeleteKeyRingRequest + }{ + { + description: "base case", + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/kms/keyring/describe/describe.go b/internal/cmd/kms/keyring/describe/describe.go new file mode 100644 index 000000000..c5c54cc7d --- /dev/null +++ b/internal/cmd/kms/keyring/describe/describe.go @@ -0,0 +1,108 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + argKeyRingID = "KEYRING_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", argKeyRingID), + Short: "Describe a KMS key ring", + Long: "Describe a KMS key ring", + Args: args.SingleArg(argKeyRingID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a KMS key ring with ID xxx`, + `$ stackit kms keyring describe xxx`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get key ring: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + model := &inputModel{ + GlobalFlagModel: globalFlags, + KeyRingID: inputArgs[0], + } + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiGetKeyRingRequest { + return apiClient.GetKeyRing(ctx, model.ProjectId, model.Region, model.KeyRingID) +} + +func outputResult(p *print.Printer, outputFormat string, keyRing *kms.KeyRing) error { + if keyRing == nil { + return fmt.Errorf("key ring response is empty") + } + return p.OutputResult(outputFormat, keyRing, func() error { + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(keyRing.Id)) + table.AddSeparator() + table.AddRow("DISPLAY NAME", utils.PtrString(keyRing.DisplayName)) + table.AddSeparator() + table.AddRow("CREATED AT", utils.PtrString(keyRing.CreatedAt)) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(keyRing.State)) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(keyRing.Description)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/kms/keyring/describe/describe_test.go b/internal/cmd/kms/keyring/describe/describe_test.go new file mode 100644 index 000000000..cc0a8abdb --- /dev/null +++ b/internal/cmd/kms/keyring/describe/describe_test.go @@ -0,0 +1,185 @@ +package describe + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &kms.APIClient{} +var testProjectId = uuid.NewString() +var testKeyRingID = uuid.NewString() +var testTime = time.Time{} + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingID: testKeyRingID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: []string{testKeyRingID}, + 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: []string{testKeyRingID}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "invalid key ring id", + argValues: []string{"!invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing project id", + argValues: []string{testKeyRingID}, + flagValues: fixtureFlagValues(func(m map[string]string) { delete(m, globalflags.ProjectIdFlag) }), + isValid: false, + }, + { + description: "invalid project id", + argValues: []string{testKeyRingID}, + flagValues: fixtureFlagValues(func(m map[string]string) { m[globalflags.ProjectIdFlag] = "invalid-uuid" }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + got := buildRequest(testCtx, fixtureInputModel(), testClient) + want := testClient.GetKeyRing(testCtx, testProjectId, testRegion, testKeyRingID) + diff := cmp.Diff(got, want, + cmp.AllowUnexported(want), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("buildRequest() mismatch (-want +got):\n%s", diff) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFmt string + keyRing *kms.KeyRing + wantErr bool + expected string + }{ + { + description: "empty", + outputFmt: "table", + wantErr: true, + }, + { + description: "table format", + outputFmt: "table", + keyRing: &kms.KeyRing{ + Id: utils.Ptr(testKeyRingID), + DisplayName: utils.Ptr("Test Key Ring"), + CreatedAt: utils.Ptr(testTime), + Description: utils.Ptr("This is a test key ring."), + State: utils.Ptr(kms.KEYRINGSTATE_ACTIVE), + }, + expected: fmt.Sprintf(` + ID │ %-37s +──────────────┼────────────────────────────────────── + DISPLAY NAME │ Test Key Ring +──────────────┼────────────────────────────────────── + CREATED AT │ %-37s +──────────────┼────────────────────────────────────── + STATE │ active +──────────────┼────────────────────────────────────── + DESCRIPTION │ This is a test key ring. + +`, + testKeyRingID, + testTime, + ), + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var buf bytes.Buffer + p.Cmd.SetOut(&buf) + if err := outputResult(p, tt.outputFmt, tt.keyRing); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + diff := cmp.Diff(buf.String(), tt.expected) + if diff != "" { + t.Fatalf("outputResult() output mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/kms/keyring/keyring.go b/internal/cmd/kms/keyring/keyring.go new file mode 100644 index 000000000..237991e5c --- /dev/null +++ b/internal/cmd/kms/keyring/keyring.go @@ -0,0 +1,32 @@ +package keyring + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/keyring/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/keyring/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/keyring/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/keyring/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "keyring", + Short: "Manage KMS key rings", + Long: "Provides functionality for key ring operations inside the KMS", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) +} diff --git a/internal/cmd/kms/keyring/list/list.go b/internal/cmd/kms/keyring/list/list.go new file mode 100644 index 000000000..4b3b09d67 --- /dev/null +++ b/internal/cmd/kms/keyring/list/list.go @@ -0,0 +1,117 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all KMS key rings", + Long: "Lists all KMS key rings.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all KMS key rings`, + "$ stackit kms keyring list"), + examples.NewExample( + `List all KMS key rings in JSON format`, + "$ stackit kms keyring list --output-format json"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get KMS key rings: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.ProjectId, resp) + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiListKeyRingsRequest { + req := apiClient.ListKeyRings(ctx, model.ProjectId, model.Region) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectId string, resp *kms.KeyRingList) error { + if resp == nil || resp.KeyRings == nil { + return fmt.Errorf("response was nil / empty") + } + + keyRings := *resp.KeyRings + + return p.OutputResult(outputFormat, keyRings, func() error { + if len(keyRings) == 0 { + p.Outputf("No key rings found for project %q\n", projectId) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATUS") + + for i := range keyRings { + keyRing := keyRings[i] + table.AddRow( + utils.PtrString(keyRing.Id), + utils.PtrString(keyRing.DisplayName), + utils.PtrString(keyRing.State), + ) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/kms/keyring/list/list_test.go b/internal/cmd/kms/keyring/list/list_test.go new file mode 100644 index 000000000..7fc3122fc --- /dev/null +++ b/internal/cmd/kms/keyring/list/list_test.go @@ -0,0 +1,231 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiListKeyRingsRequest)) kms.ApiListKeyRingsRequest { + request := testClient.ListKeyRings(testCtx, testProjectId, testRegion) + 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 provided", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) + } + + p := print.NewPrinter() + model, err := parseInput(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiListKeyRingsRequest + }{ + { + description: "base case", + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + projectId string + resp *kms.KeyRingList + outputFormat string + projectLabel string + wantErr bool + }{ + { + description: "nil response", + resp: nil, + projectId: uuid.NewString(), + projectLabel: "my-project", + wantErr: true, + }, + { + description: "empty response", + resp: &kms.KeyRingList{}, + projectId: uuid.NewString(), + projectLabel: "my-project", + wantErr: true, + }, + { + description: "default output", + projectId: uuid.NewString(), + resp: &kms.KeyRingList{KeyRings: &[]kms.KeyRing{}}, + projectLabel: "my-project", + wantErr: false, + }, + { + description: "json output", + projectId: uuid.NewString(), + resp: &kms.KeyRingList{KeyRings: &[]kms.KeyRing{}}, + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + description: "yaml output", + projectId: uuid.NewString(), + resp: &kms.KeyRingList{KeyRings: &[]kms.KeyRing{}}, + outputFormat: print.YAMLOutputFormat, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.projectId, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/kms.go b/internal/cmd/kms/kms.go new file mode 100644 index 000000000..46dc38c0b --- /dev/null +++ b/internal/cmd/kms/kms.go @@ -0,0 +1,32 @@ +package kms + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/key" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/keyring" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/version" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/wrappingkey" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "kms", + Short: "Provides functionality for KMS", + Long: "Provides functionality for KMS.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(keyring.NewCmd(params)) + cmd.AddCommand(wrappingkey.NewCmd(params)) + cmd.AddCommand(key.NewCmd(params)) + cmd.AddCommand(version.NewCmd(params)) +} diff --git a/internal/cmd/kms/version/destroy/destroy.go b/internal/cmd/kms/version/destroy/destroy.go new file mode 100644 index 000000000..bc56351c0 --- /dev/null +++ b/internal/cmd/kms/version/destroy/destroy.go @@ -0,0 +1,131 @@ +package destroy + +import ( + "context" + "fmt" + "strconv" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + versionNumberArg = "VERSION_NUMBER" + + keyRingIdFlag = "keyring-id" + keyIdFlag = "key-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string + KeyId string + VersionNumber int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("destroy %s", versionNumberArg), + Short: "Destroy a key version", + Long: "Removes the key material of a version.", + Args: args.SingleArg(versionNumberArg, nil), + Example: examples.Build( + examples.NewExample( + `Destroy key version "42" for the key "my-key-id" inside the key ring "my-keyring-id"`, + `$ stackit kms version destroy 42 --key-id "my-key-id" --keyring-id "my-keyring-id"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // This operation can be undone. Don't ask for confirmation! + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("destroy key Version: %w", err) + } + + // Get the key version in its state afterwards + resp, err := apiClient.GetVersionExecute(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key version: %v", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + versionStr := inputArgs[0] + versionNumber, err := strconv.ParseInt(versionStr, 10, 64) + if err != nil || versionNumber < 0 { + return nil, &errors.ArgValidationError{ + Arg: versionNumberArg, + Details: fmt.Sprintf("invalid value %q: must be a positive integer", versionStr), + } + } + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + KeyId: flags.FlagToStringValue(p, cmd, keyIdFlag), + VersionNumber: versionNumber, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiDestroyVersionRequest { + return apiClient.DestroyVersion(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring") + cmd.Flags().Var(flags.UUIDFlag(), keyIdFlag, "ID of the key") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag, keyIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat string, resp *kms.Version) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Destroyed version %d of the key %q\n", utils.PtrValue(resp.Number), utils.PtrValue(resp.KeyId)) + return nil + }) +} diff --git a/internal/cmd/kms/version/destroy/destroy_test.go b/internal/cmd/kms/version/destroy/destroy_test.go new file mode 100644 index 000000000..0b2816315 --- /dev/null +++ b/internal/cmd/kms/version/destroy/destroy_test.go @@ -0,0 +1,321 @@ +package destroy + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" + testVersionNumber = int64(1) + testVersionNumberString = "1" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testVersionNumberString, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + keyIdFlag: testKeyId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + VersionNumber: testVersionNumber, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiDestroyVersionRequest)) kms.ApiDestroyVersionRequest { + request := testClient.DestroyVersion(testCtx, testProjectId, testRegion, testKeyRingId, testKeyId, testVersionNumber) + 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (versionNumber)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyIdFlag) + }), + isValid: false, + }, + { + description: "key id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "" + }), + isValid: false, + }, + { + description: "key id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "version number invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "version number invalid 2", + argValues: []string{"Not a Number!"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiDestroyVersionRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + wantErr bool + outputFormat string + resp *kms.Version + }{ + { + description: "nil response", + resp: nil, + wantErr: true, + }, + { + description: "default output", + resp: &kms.Version{}, + wantErr: false, + }, + { + description: "json output", + resp: &kms.Version{}, + wantErr: false, + }, + { + description: "yaml output", + resp: &kms.Version{}, + outputFormat: print.YAMLOutputFormat, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/version/disable/disable.go b/internal/cmd/kms/version/disable/disable.go new file mode 100644 index 000000000..00d92b3f9 --- /dev/null +++ b/internal/cmd/kms/version/disable/disable.go @@ -0,0 +1,148 @@ +package disable + +import ( + "context" + "fmt" + "strconv" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/stackit-sdk-go/services/kms/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + versionNumberArg = "VERSION_NUMBER" + + keyRingIdFlag = "keyring-id" + keyIdFlag = "key-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string + KeyId string + VersionNumber int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("disable %s", versionNumberArg), + Short: "Disable a key version", + Long: "Disable the given key version.", + Args: args.SingleArg(versionNumberArg, nil), + Example: examples.Build( + examples.NewExample( + `Disable key version "42" for the key "my-key-id" inside the key ring "my-keyring-id"`, + `$ stackit kms version disable 42 --key-id "my-key-id" --keyring-id "my-keyring-id"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // This operation can be undone. Don't ask for confirmation! + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("disable key version: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Disabling key version", func() error { + _, err = wait.DisableKeyVersionWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for key version to be disabled: %w", err) + } + } + + // Get the key version in its state afterwards + resp, err := apiClient.GetVersionExecute(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key version: %v", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + versionStr := inputArgs[0] + versionNumber, err := strconv.ParseInt(versionStr, 10, 64) + if err != nil || versionNumber < 0 { + return nil, &errors.ArgValidationError{ + Arg: versionNumberArg, + Details: fmt.Sprintf("invalid value %q: must be a positive integer", versionStr), + } + } + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + KeyId: flags.FlagToStringValue(p, cmd, keyIdFlag), + VersionNumber: versionNumber, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiDisableVersionRequest { + return apiClient.DisableVersion(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring") + cmd.Flags().Var(flags.UUIDFlag(), keyIdFlag, "ID of the key") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag, keyIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat string, async bool, resp *kms.Version) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + operationState := "Disabled" + if async { + operationState = "Triggered disable of" + } + p.Outputf("%s version %d of the key %q\n", operationState, utils.PtrValue(resp.Number), utils.PtrValue(resp.KeyId)) + return nil + }) +} diff --git a/internal/cmd/kms/version/disable/disable_test.go b/internal/cmd/kms/version/disable/disable_test.go new file mode 100644 index 000000000..ad81db570 --- /dev/null +++ b/internal/cmd/kms/version/disable/disable_test.go @@ -0,0 +1,323 @@ +package disable + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" + testVersionNumber = int64(1) + testVersionNumberString = "1" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testVersionNumberString, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + keyIdFlag: testKeyId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + VersionNumber: testVersionNumber, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiDisableVersionRequest)) kms.ApiDisableVersionRequest { + request := testClient.DisableVersion(testCtx, testProjectId, testRegion, testKeyRingId, testKeyId, testVersionNumber) + 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (versionNumber)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyIdFlag) + }), + isValid: false, + }, + { + description: "key id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "" + }), + isValid: false, + }, + { + description: "key id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "version number invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "version number invalid 2", + argValues: []string{"Not a Number!"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiDisableVersionRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + wantErr bool + outputFormat string + async bool + resp *kms.Version + }{ + { + description: "nil response", + resp: nil, + wantErr: true, + }, + { + description: "default output", + resp: &kms.Version{}, + wantErr: false, + }, + { + description: "json output", + outputFormat: print.JSONOutputFormat, + resp: &kms.Version{}, + wantErr: false, + }, + { + description: "yaml output", + outputFormat: print.YAMLOutputFormat, + resp: &kms.Version{}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.async, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/version/enable/enable.go b/internal/cmd/kms/version/enable/enable.go new file mode 100644 index 000000000..909cf3a32 --- /dev/null +++ b/internal/cmd/kms/version/enable/enable.go @@ -0,0 +1,148 @@ +package enable + +import ( + "context" + "fmt" + "strconv" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/stackit-sdk-go/services/kms/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + versionNumberArg = "VERSION_NUMBER" + + keyRingIdFlag = "keyring-id" + keyIdFlag = "key-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string + KeyId string + VersionNumber int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("enable %s", versionNumberArg), + Short: "Enable a key version", + Long: "Enable the given key version.", + Args: args.SingleArg(versionNumberArg, nil), + Example: examples.Build( + examples.NewExample( + `Enable key version "42" for the key "my-key-id" inside the key ring "my-keyring-id"`, + `$ stackit kms version enable 42 --key-id "my-key-id" --keyring-id "my-keyring-id"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // This operation can be undone. Don't ask for confirmation! + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("enable key version: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Enabling key version", func() error { + _, err = wait.EnableKeyVersionWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for key version to be enabled: %w", err) + } + } + + // Get the key version in its state afterwards + resp, err := apiClient.GetVersionExecute(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key version: %v", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + versionStr := inputArgs[0] + versionNumber, err := strconv.ParseInt(versionStr, 10, 64) + if err != nil || versionNumber < 0 { + return nil, &errors.ArgValidationError{ + Arg: versionNumberArg, + Details: fmt.Sprintf("invalid value %q: must be a positive integer", versionStr), + } + } + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + KeyId: flags.FlagToStringValue(p, cmd, keyIdFlag), + VersionNumber: versionNumber, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiEnableVersionRequest { + return apiClient.EnableVersion(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring") + cmd.Flags().Var(flags.UUIDFlag(), keyIdFlag, "ID of the key") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag, keyIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat string, async bool, resp *kms.Version) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(outputFormat, resp, func() error { + operationState := "Enabled" + if async { + operationState = "Triggered enable of" + } + p.Outputf("%s version %d of the key %q\n", operationState, utils.PtrValue(resp.Number), utils.PtrValue(resp.KeyId)) + return nil + }) +} diff --git a/internal/cmd/kms/version/enable/enable_test.go b/internal/cmd/kms/version/enable/enable_test.go new file mode 100644 index 000000000..3e176d217 --- /dev/null +++ b/internal/cmd/kms/version/enable/enable_test.go @@ -0,0 +1,323 @@ +package enable + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" + testVersionNumber = int64(1) + testVersionNumberString = "1" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testVersionNumberString, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + keyIdFlag: testKeyId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + VersionNumber: testVersionNumber, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiEnableVersionRequest)) kms.ApiEnableVersionRequest { + request := testClient.EnableVersion(testCtx, testProjectId, testRegion, testKeyRingId, testKeyId, testVersionNumber) + 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (versionNumber)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyIdFlag) + }), + isValid: false, + }, + { + description: "key id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "" + }), + isValid: false, + }, + { + description: "key id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "version number invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "version number invalid 2", + argValues: []string{"Not a Number!"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiEnableVersionRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + wantErr bool + outputFormat string + async bool + resp *kms.Version + }{ + { + description: "nil response", + resp: nil, + wantErr: true, + }, + { + description: "default output", + resp: &kms.Version{}, + wantErr: false, + }, + { + description: "json output", + resp: &kms.Version{}, + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + description: "yaml output", + resp: &kms.Version{}, + outputFormat: print.YAMLOutputFormat, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.async, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/version/list/list.go b/internal/cmd/kms/version/list/list.go new file mode 100644 index 000000000..bae8aa2e2 --- /dev/null +++ b/internal/cmd/kms/version/list/list.go @@ -0,0 +1,134 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + keyRingIdFlag = "keyring-id" + keyIdFlag = "key-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string + KeyId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all key versions", + Long: "List all versions of a given key.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all key versions for the key "my-key-id" inside the key ring "my-keyring-id"`, + `$ stackit kms version list --key-id "my-key-id" --keyring-id "my-keyring-id"`), + examples.NewExample( + `List all key versions in JSON format`, + `$ stackit kms version list --key-id "my-key-id" --keyring-id "my-keyring-id" -o json`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get key version: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.ProjectId, model.KeyId, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + KeyId: flags.FlagToStringValue(p, cmd, keyIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiListVersionsRequest { + return apiClient.ListVersions(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId) +} + +func outputResult(p *print.Printer, outputFormat, projectId, keyId string, resp *kms.VersionList) error { + if resp == nil || resp.Versions == nil { + return fmt.Errorf("response is nil / empty") + } + versions := *resp.Versions + + return p.OutputResult(outputFormat, versions, func() error { + if len(versions) == 0 { + p.Outputf("No key versions found for project %q for the key %q\n", projectId, keyId) + return nil + } + table := tables.NewTable() + table.SetHeader("ID", "NUMBER", "CREATED AT", "DESTROY DATE", "STATUS") + + for _, version := range versions { + table.AddRow( + utils.PtrString(version.KeyId), + utils.PtrString(version.Number), + utils.PtrString(version.CreatedAt), + utils.PtrString(version.DestroyDate), + utils.PtrString(version.State), + ) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring") + cmd.Flags().Var(flags.UUIDFlag(), keyIdFlag, "ID of the key") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag, keyIdFlag) + cobra.CheckErr(err) +} diff --git a/internal/cmd/kms/version/list/list_test.go b/internal/cmd/kms/version/list/list_test.go new file mode 100644 index 000000000..eaede273b --- /dev/null +++ b/internal/cmd/kms/version/list/list_test.go @@ -0,0 +1,284 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + keyIdFlag: testKeyId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiListVersionsRequest)) kms.ApiListVersionsRequest { + request := testClient.ListVersions(testCtx, testProjectId, testRegion, testKeyRingId, testKeyId) + 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyIdFlag) + }), + isValid: false, + }, + { + description: "key id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "" + }), + isValid: false, + }, + { + description: "key id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "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) + } + + 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) + } + + p := print.NewPrinter() + model, err := parseInput(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiListVersionsRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + projectId string + keyId string + resp *kms.VersionList + outputFormat string + projectLabel string + wantErr bool + }{ + { + description: "nil response", + resp: nil, + projectLabel: "my-project", + wantErr: true, + }, + { + description: "empty default", + resp: &kms.VersionList{}, + projectLabel: "my-project", + wantErr: true, + }, + { + description: "default output", + resp: &kms.VersionList{Versions: &[]kms.Version{}}, + projectLabel: "my-project", + wantErr: false, + }, + { + description: "json output", + resp: &kms.VersionList{Versions: &[]kms.Version{}}, + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + description: "yaml output", + resp: &kms.VersionList{Versions: &[]kms.Version{}}, + outputFormat: print.YAMLOutputFormat, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.projectId, tt.keyId, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/version/restore/restore.go b/internal/cmd/kms/version/restore/restore.go new file mode 100644 index 000000000..c8a850a62 --- /dev/null +++ b/internal/cmd/kms/version/restore/restore.go @@ -0,0 +1,131 @@ +package restore + +import ( + "context" + "fmt" + "strconv" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + versionNumberArg = "VERSION_NUMBER" + + keyRingIdFlag = "keyring-id" + keyIdFlag = "key-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string + KeyId string + VersionNumber int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("restore %s", versionNumberArg), + Short: "Restore a key version", + Long: "Restores the specified version of a key.", + Args: args.SingleArg(versionNumberArg, nil), + Example: examples.Build( + examples.NewExample( + `Restore key version "42" for the key "my-key-id" inside the key ring "my-keyring-id"`, + `$ stackit kms version restore 42 --key-id "my-key-id" --keyring-id "my-keyring-id"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // This operation can be undone. Don't ask for confirmation! + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("restore key Version: %w", err) + } + + // Grab the key after the restore was applied to display the new state to the user. + resp, err := apiClient.GetVersionExecute(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get key version: %v", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + versionStr := inputArgs[0] + versionNumber, err := strconv.ParseInt(versionStr, 10, 64) + if err != nil || versionNumber < 0 { + return nil, &errors.ArgValidationError{ + Arg: versionNumberArg, + Details: fmt.Sprintf("invalid value %q: must be a positive integer", versionStr), + } + } + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + KeyId: flags.FlagToStringValue(p, cmd, keyIdFlag), + VersionNumber: versionNumber, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiRestoreVersionRequest { + return apiClient.RestoreVersion(ctx, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber) +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring") + cmd.Flags().Var(flags.UUIDFlag(), keyIdFlag, "ID of the key") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag, keyIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat string, resp *kms.Version) error { + if resp == nil { + return fmt.Errorf("response is nil / empty") + } + + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Restored version %d of the key %q\n", utils.PtrValue(resp.Number), utils.PtrValue(resp.KeyId)) + return nil + }) +} diff --git a/internal/cmd/kms/version/restore/restore_test.go b/internal/cmd/kms/version/restore/restore_test.go new file mode 100644 index 000000000..e51c94316 --- /dev/null +++ b/internal/cmd/kms/version/restore/restore_test.go @@ -0,0 +1,322 @@ +package restore + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" + testVersionNumber = int64(1) + testVersionNumberString = "1" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testVersionNumberString, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + keyIdFlag: testKeyId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + KeyId: testKeyId, + VersionNumber: testVersionNumber, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiRestoreVersionRequest)) kms.ApiRestoreVersionRequest { + request := testClient.RestoreVersion(testCtx, testProjectId, testRegion, testKeyRingId, testKeyId, testVersionNumber) + 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (versionNumber)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "key ring id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyIdFlag) + }), + isValid: false, + }, + { + description: "key id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "" + }), + isValid: false, + }, + { + description: "key id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "version number invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "version number invalid 2", + argValues: []string{"Not a Number!"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiRestoreVersionRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + wantErr bool + outputFormat string + resp *kms.Version + }{ + { + description: "nil response", + resp: nil, + wantErr: true, + }, + { + description: "default output", + resp: &kms.Version{}, + wantErr: false, + }, + { + description: "json output", + outputFormat: print.JSONOutputFormat, + resp: &kms.Version{}, + wantErr: false, + }, + { + description: "yaml output", + outputFormat: print.YAMLOutputFormat, + resp: &kms.Version{}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/version/version.go b/internal/cmd/kms/version/version.go new file mode 100644 index 000000000..e1a7d56fe --- /dev/null +++ b/internal/cmd/kms/version/version.go @@ -0,0 +1,34 @@ +package version + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/version/destroy" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/version/disable" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/version/enable" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/version/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/version/restore" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "version", + Short: "Manage KMS key versions", + Long: "Provides functionality for key version operations inside the KMS", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(destroy.NewCmd(params)) + cmd.AddCommand(disable.NewCmd(params)) + cmd.AddCommand(enable.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(restore.NewCmd(params)) +} diff --git a/internal/cmd/kms/wrappingkey/create/create.go b/internal/cmd/kms/wrappingkey/create/create.go new file mode 100644 index 000000000..3397f112f --- /dev/null +++ b/internal/cmd/kms/wrappingkey/create/create.go @@ -0,0 +1,187 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + "github.com/stackitcloud/stackit-sdk-go/services/kms/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + keyRingIdFlag = "keyring-id" + + algorithmFlag = "algorithm" + descriptionFlag = "description" + displayNameFlag = "name" + purposeFlag = "purpose" + protectionFlag = "protection" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string + + Algorithm *string + Description *string + Name *string + Purpose *string + Protection *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a KMS wrapping key", + Long: "Creates a KMS wrapping key.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a symmetric (RSA + AES) KMS wrapping key with name "my-wrapping-key-name" in key ring with ID "my-keyring-id"`, + `$ stackit kms wrapping-key create --keyring-id "my-keyring-id" --algorithm "rsa_2048_oaep_sha256_aes_256_key_wrap" --name "my-wrapping-key-name" --purpose "wrap_symmetric_key" --protection "software"`), + examples.NewExample( + `Create an asymmetric (RSA) KMS wrapping key with name "my-wrapping-key-name" in key ring with ID "my-keyring-id"`, + `$ stackit kms wrapping-key create --keyring-id "my-keyring-id" --algorithm "rsa_3072_oaep_sha256" --name "my-wrapping-key-name" --purpose "wrap_asymmetric_key" --protection "software"`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + err = params.Printer.PromptForConfirmation("Are you sure you want to create a KMS wrapping key?") + if err != nil { + return err + } + + // Call API + req, _ := buildRequest(ctx, model, apiClient) + wrappingKey, err := req.Execute() + if err != nil { + return fmt.Errorf("create KMS wrapping key: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating wrapping key", func() error { + _, err = wait.CreateWrappingKeyWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *wrappingKey.KeyRingId, *wrappingKey.Id).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for KMS wrapping key creation: %w", err) + } + } + + return outputResult(params.Printer, model, wrappingKey) + }, + } + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + // All values are mandatory strings. No additional type check required. + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + Algorithm: flags.FlagToStringPointer(p, cmd, algorithmFlag), + Name: flags.FlagToStringPointer(p, cmd, displayNameFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Purpose: flags.FlagToStringPointer(p, cmd, purposeFlag), + Protection: flags.FlagToStringPointer(p, cmd, protectionFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +type kmsWrappingKeyClient interface { + CreateWrappingKey(ctx context.Context, projectId string, regionId string, keyRingId string) kms.ApiCreateWrappingKeyRequest +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient kmsWrappingKeyClient) (kms.ApiCreateWrappingKeyRequest, error) { + req := apiClient.CreateWrappingKey(ctx, model.ProjectId, model.Region, model.KeyRingId) + + req = req.CreateWrappingKeyPayload(kms.CreateWrappingKeyPayload{ + DisplayName: model.Name, + Description: model.Description, + Algorithm: kms.CreateWrappingKeyPayloadGetAlgorithmAttributeType(model.Algorithm), + Purpose: kms.CreateWrappingKeyPayloadGetPurposeAttributeType(model.Purpose), + Protection: kms.CreateWrappingKeyPayloadGetProtectionAttributeType(model.Protection), + }) + return req, nil +} + +func outputResult(p *print.Printer, model *inputModel, resp *kms.WrappingKey) error { + if resp == nil { + return fmt.Errorf("response is nil") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s wrapping key. Wrapping key ID: %s\n", operationState, utils.PtrString(resp.Id)) + return nil + }) +} + +func configureFlags(cmd *cobra.Command) { + // Algorithm + var algorithmFlagOptions []string + for _, val := range kms.AllowedWrappingAlgorithmEnumValues { + algorithmFlagOptions = append(algorithmFlagOptions, string(val)) + } + cmd.Flags().Var(flags.EnumFlag(false, "", algorithmFlagOptions...), algorithmFlag, fmt.Sprintf("En-/Decryption / signing algorithm. Possible values: %q", algorithmFlagOptions)) + + // Purpose + var purposeFlagOptions []string + for _, val := range kms.AllowedWrappingPurposeEnumValues { + purposeFlagOptions = append(purposeFlagOptions, string(val)) + } + cmd.Flags().Var(flags.EnumFlag(false, "", purposeFlagOptions...), purposeFlag, fmt.Sprintf("Purpose of the wrapping key. Possible values: %q", purposeFlagOptions)) + + // Protection + // backend was deprectaed in /v1beta, but protection is a required attribute with value "software" + var protectionFlagOptions []string + for _, val := range kms.AllowedProtectionEnumValues { + protectionFlagOptions = append(protectionFlagOptions, string(val)) + } + cmd.Flags().Var(flags.EnumFlag(false, "", protectionFlagOptions...), protectionFlag, fmt.Sprintf("The underlying system that is responsible for protecting the wrapping key material. Possible values: %q", purposeFlagOptions)) + + // All further non Enum Flags + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring") + cmd.Flags().String(displayNameFlag, "", "The display name to distinguish multiple wrapping keys") + cmd.Flags().String(descriptionFlag, "", "Optional description of the wrapping key") + + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag, algorithmFlag, purposeFlag, displayNameFlag, protectionFlag) + cobra.CheckErr(err) +} diff --git a/internal/cmd/kms/wrappingkey/create/create_test.go b/internal/cmd/kms/wrappingkey/create/create_test.go new file mode 100644 index 000000000..737b88ef2 --- /dev/null +++ b/internal/cmd/kms/wrappingkey/create/create_test.go @@ -0,0 +1,320 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testRegion = "eu01" + testAlgorithm = "rsa_2048_oaep_sha256" + testDisplayName = "my-key" + testPurpose = "wrap_asymmetric_key" + testDescription = "my key description" + testProtection = "software" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + algorithmFlag: testAlgorithm, + displayNameFlag: testDisplayName, + purposeFlag: testPurpose, + descriptionFlag: testDescription, + protectionFlag: testProtection, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + Algorithm: utils.Ptr(testAlgorithm), + Name: utils.Ptr(testDisplayName), + Purpose: utils.Ptr(testPurpose), + Description: utils.Ptr(testDescription), + Protection: utils.Ptr(testProtection), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiCreateWrappingKeyRequest)) kms.ApiCreateWrappingKeyRequest { + request := testClient.CreateWrappingKey(testCtx, testProjectId, testRegion, testKeyRingId) + request = request.CreateWrappingKeyPayload(kms.CreateWrappingKeyPayload{ + Algorithm: kms.CreateWrappingKeyPayloadGetAlgorithmAttributeType(utils.Ptr(testAlgorithm)), + DisplayName: utils.Ptr(testDisplayName), + Purpose: kms.CreateWrappingKeyPayloadGetPurposeAttributeType(utils.Ptr(testPurpose)), + Description: utils.Ptr(testDescription), + Protection: kms.CreateWrappingKeyPayloadGetProtectionAttributeType(utils.Ptr(testProtection)), + }) + + 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: "optional flags omitted", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + }, + { + description: "no values provided", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "algorithm missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, algorithmFlag) + }), + isValid: false, + }, + { + description: "name missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "purpose missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, purposeFlag) + }), + isValid: false, + }, + { + description: "protection missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, protectionFlag) + }), + 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) + } + + p := print.NewPrinter() + model, err := parseInput(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiCreateWrappingKeyRequest + }{ + { + description: "base case", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optional values", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + expectedRequest: fixtureRequest().CreateWrappingKeyPayload(kms.CreateWrappingKeyPayload{ + Algorithm: kms.CreateWrappingKeyPayloadGetAlgorithmAttributeType(utils.Ptr(testAlgorithm)), + DisplayName: utils.Ptr(testDisplayName), + Purpose: kms.CreateWrappingKeyPayloadGetPurposeAttributeType(utils.Ptr(testPurpose)), + Protection: kms.CreateWrappingKeyPayloadGetProtectionAttributeType(utils.Ptr(testProtection)), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + wrappingKey *kms.WrappingKey + wantErr bool + }{ + { + description: "nil response", + wrappingKey: nil, + wantErr: true, + }, + { + description: "default output", + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + wrappingKey: &kms.WrappingKey{}, + wantErr: false, + }, + { + description: "json output", + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + wrappingKey: &kms.WrappingKey{}, + wantErr: false, + }, + { + description: "yaml output", + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.YAMLOutputFormat}}, + wrappingKey: &kms.WrappingKey{}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.model, tt.wrappingKey) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/wrappingkey/delete/delete.go b/internal/cmd/kms/wrappingkey/delete/delete.go new file mode 100644 index 000000000..cd1ce3f6a --- /dev/null +++ b/internal/cmd/kms/wrappingkey/delete/delete.go @@ -0,0 +1,119 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + kmsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" +) + +const ( + wrappingKeyIdArg = "WRAPPING_KEY_ID" + + keyRingIdFlag = "keyring-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + WrappingKeyId string + KeyRingId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", wrappingKeyIdArg), + Short: "Deletes a KMS wrapping key", + Long: "Deletes a KMS wrapping key inside a specific key ring.", + Args: args.SingleArg(wrappingKeyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a KMS wrapping key "MY_WRAPPING_KEY_ID" inside the key ring "my-keyring-id"`, + `$ stackit kms wrapping-key delete "MY_WRAPPING_KEY_ID" --keyring-id "my-keyring-id"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + wrappingKeyName, err := kmsUtils.GetWrappingKeyName(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.WrappingKeyId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get wrapping key name: %v", err) + wrappingKeyName = model.WrappingKeyId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the wrapping key %q? (this cannot be undone)", wrappingKeyName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete KMS wrapping key: %w", err) + } + + // Wait for async operation not relevant. Wrapping key deletion is synchronous + + // Don't output anything. It's a deletion. + params.Printer.Info("Deleted wrapping key %q\n", wrappingKeyName) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + wrappingKeyId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + WrappingKeyId: wrappingKeyId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiDeleteWrappingKeyRequest { + req := apiClient.DeleteWrappingKey(ctx, model.ProjectId, model.Region, model.KeyRingId, model.WrappingKeyId) + return req +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring where the wrapping key is stored") + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag) + cobra.CheckErr(err) +} diff --git a/internal/cmd/kms/wrappingkey/delete/delete_test.go b/internal/cmd/kms/wrappingkey/delete/delete_test.go new file mode 100644 index 000000000..8b6460695 --- /dev/null +++ b/internal/cmd/kms/wrappingkey/delete/delete_test.go @@ -0,0 +1,243 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testWrappingKeyId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testWrappingKeyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + WrappingKeyId: testWrappingKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiDeleteWrappingKeyRequest)) kms.ApiDeleteWrappingKeyRequest { + request := testClient.DeleteWrappingKey(testCtx, testProjectId, testRegion, testKeyRingId, testWrappingKeyId) + 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 (wrappingKeyId)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values provided", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "key ring id missing (required)", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "key ring id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "wrapping key id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "wrapping key id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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) + } + + 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(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiDeleteWrappingKeyRequest + }{ + { + description: "base case", + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/kms/wrappingkey/describe/describe.go b/internal/cmd/kms/wrappingkey/describe/describe.go new file mode 100644 index 000000000..e58e9fa81 --- /dev/null +++ b/internal/cmd/kms/wrappingkey/describe/describe.go @@ -0,0 +1,133 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + argWrappingKeyID = "WRAPPING_KEY_ID" + flagKeyRingID = "keyring-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + WrappingKeyID string + KeyRingID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", argWrappingKeyID), + Short: "Describe a KMS wrapping key", + Long: "Describe a KMS wrapping key", + Args: args.SingleArg(argWrappingKeyID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a KMS wrapping key with ID xxx of keyring yyy`, + `$ stackit kms wrapping-key describe xxx --keyring-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get wrapping key: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), flagKeyRingID, "Key Ring ID") + err := flags.MarkFlagsRequired(cmd, flagKeyRingID) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + model := &inputModel{ + GlobalFlagModel: globalFlags, + WrappingKeyID: inputArgs[0], + KeyRingID: flags.FlagToStringValue(p, cmd, flagKeyRingID), + } + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiGetWrappingKeyRequest { + return apiClient.GetWrappingKey(ctx, model.ProjectId, model.Region, model.KeyRingID, model.WrappingKeyID) +} + +func outputResult(p *print.Printer, outputFormat string, wrappingKey *kms.WrappingKey) error { + if wrappingKey == nil { + return fmt.Errorf("wrapping key response is empty") + } + return p.OutputResult(outputFormat, wrappingKey, func() error { + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(wrappingKey.Id)) + table.AddSeparator() + table.AddRow("DISPLAY NAME", utils.PtrString(wrappingKey.DisplayName)) + table.AddSeparator() + table.AddRow("CREATED AT", utils.PtrString(wrappingKey.CreatedAt)) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(wrappingKey.State)) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(wrappingKey.Description)) + table.AddSeparator() + table.AddRow("ACCESS SCOPE", utils.PtrString(wrappingKey.AccessScope)) + table.AddSeparator() + table.AddRow("ALGORITHM", utils.PtrString(wrappingKey.Algorithm)) + table.AddSeparator() + table.AddRow("EXPIRES AT", utils.PtrString(wrappingKey.ExpiresAt)) + table.AddSeparator() + table.AddRow("KEYRING ID", utils.PtrString(wrappingKey.KeyRingId)) + table.AddSeparator() + table.AddRow("PROTECTION", utils.PtrString(wrappingKey.Protection)) + table.AddSeparator() + table.AddRow("PUBLIC KEY", utils.PtrString(wrappingKey.PublicKey)) + table.AddSeparator() + table.AddRow("PURPOSE", utils.PtrString(wrappingKey.Purpose)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/kms/wrappingkey/describe/describe_test.go b/internal/cmd/kms/wrappingkey/describe/describe_test.go new file mode 100644 index 000000000..6855c27bd --- /dev/null +++ b/internal/cmd/kms/wrappingkey/describe/describe_test.go @@ -0,0 +1,217 @@ +package describe + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &kms.APIClient{} +var testProjectId = uuid.NewString() +var testKeyRingID = uuid.NewString() +var testWrappingKeyID = uuid.NewString() +var testTime = time.Time{} + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + flagKeyRingID: testKeyRingID, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingID: testKeyRingID, + WrappingKeyID: testWrappingKeyID, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: []string{testWrappingKeyID}, + 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: []string{testWrappingKeyID}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "invalid key ring id", + argValues: []string{testWrappingKeyID}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[flagKeyRingID] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing project id", + argValues: []string{testWrappingKeyID}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id", + argValues: []string{testWrappingKeyID}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + got := buildRequest(testCtx, fixtureInputModel(), testClient) + want := testClient.GetWrappingKey(testCtx, testProjectId, testRegion, testKeyRingID, testWrappingKeyID) + diff := cmp.Diff(got, want, + cmp.AllowUnexported(want), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("buildRequest() mismatch (-want +got):\n%s", diff) + } +} +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + outputFmt string + keyRing *kms.WrappingKey + wantErr bool + expected string + }{ + { + description: "empty", + outputFmt: "table", + wantErr: true, + }, + { + description: "table format", + outputFmt: "table", + keyRing: &kms.WrappingKey{ + Id: utils.Ptr(testWrappingKeyID), + DisplayName: utils.Ptr("Test Key Ring"), + CreatedAt: utils.Ptr(testTime), + Description: utils.Ptr("This is a test key ring."), + State: utils.Ptr(kms.WRAPPINGKEYSTATE_ACTIVE), + AccessScope: utils.Ptr(kms.ACCESSSCOPE_PUBLIC), + Algorithm: utils.Ptr(kms.WRAPPINGALGORITHM__2048_OAEP_SHA256), + ExpiresAt: utils.Ptr(testTime), + KeyRingId: utils.Ptr(testKeyRingID), + Protection: utils.Ptr(kms.PROTECTION_SOFTWARE), + PublicKey: utils.Ptr("-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQ...\n-----END PUBLIC KEY-----"), + Purpose: utils.Ptr(kms.WRAPPINGPURPOSE_ASYMMETRIC_KEY), + }, + expected: fmt.Sprintf(` + ID │ %-46s +──────────────┼─────────────────────────────────────────────── + DISPLAY NAME │ Test Key Ring +──────────────┼─────────────────────────────────────────────── + CREATED AT │ %-46s +──────────────┼─────────────────────────────────────────────── + STATE │ active +──────────────┼─────────────────────────────────────────────── + DESCRIPTION │ This is a test key ring. +──────────────┼─────────────────────────────────────────────── + ACCESS SCOPE │ PUBLIC +──────────────┼─────────────────────────────────────────────── + ALGORITHM │ rsa_2048_oaep_sha256 +──────────────┼─────────────────────────────────────────────── + EXPIRES AT │ %-46s +──────────────┼─────────────────────────────────────────────── + KEYRING ID │ %-46s +──────────────┼─────────────────────────────────────────────── + PROTECTION │ software +──────────────┼─────────────────────────────────────────────── + PUBLIC KEY │ -----BEGIN PUBLIC KEY----- + │ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQ... + │ -----END PUBLIC KEY----- +──────────────┼─────────────────────────────────────────────── + PURPOSE │ wrap_asymmetric_key + +`, + testWrappingKeyID, + testTime, + testTime, + testKeyRingID), + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + var buf bytes.Buffer + p.Cmd.SetOut(&buf) + if err := outputResult(p, tt.outputFmt, tt.keyRing); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + diff := cmp.Diff(buf.String(), tt.expected) + if diff != "" { + t.Fatalf("outputResult() output mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/kms/wrappingkey/list/list.go b/internal/cmd/kms/wrappingkey/list/list.go new file mode 100644 index 000000000..6d64940ed --- /dev/null +++ b/internal/cmd/kms/wrappingkey/list/list.go @@ -0,0 +1,133 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/kms/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + keyRingIdFlag = "keyring-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + KeyRingId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all KMS wrapping keys", + Long: "Lists all KMS wrapping keys inside a key ring.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all KMS wrapping keys for the key ring "my-keyring-id"`, + `$ stackit kms wrapping-key list --keyring-id "my-keyring-id"`), + examples.NewExample( + `List all KMS wrapping keys in JSON format`, + `$ stackit kms wrapping-key list --keyring-id "my-keyring-id" --output-format json`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get KMS wrapping keys: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, model.KeyRingId, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + KeyRingId: flags.FlagToStringValue(p, cmd, keyRingIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *kms.APIClient) kms.ApiListWrappingKeysRequest { + req := apiClient.ListWrappingKeys(ctx, model.ProjectId, model.Region, model.KeyRingId) + return req +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), keyRingIdFlag, "ID of the KMS key ring where the key is stored") + err := flags.MarkFlagsRequired(cmd, keyRingIdFlag) + cobra.CheckErr(err) +} + +func outputResult(p *print.Printer, outputFormat, keyRingId string, resp *kms.WrappingKeyList) error { + if resp == nil || resp.WrappingKeys == nil { + return fmt.Errorf("response is nil / empty") + } + + wrappingKeys := *resp.WrappingKeys + + return p.OutputResult(outputFormat, wrappingKeys, func() error { + if len(wrappingKeys) == 0 { + p.Outputf("No wrapping keys found under the key ring %q\n", keyRingId) + return nil + } + table := tables.NewTable() + table.SetHeader("ID", "NAME", "SCOPE", "ALGORITHM", "EXPIRES AT", "STATUS") + + for i := range wrappingKeys { + wrappingKey := wrappingKeys[i] + table.AddRow( + utils.PtrString(wrappingKey.Id), + utils.PtrString(wrappingKey.DisplayName), + utils.PtrString(wrappingKey.Purpose), + utils.PtrString(wrappingKey.Algorithm), + utils.PtrString(wrappingKey.ExpiresAt), + utils.PtrString(wrappingKey.State), + ) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/kms/wrappingkey/list/list_test.go b/internal/cmd/kms/wrappingkey/list/list_test.go new file mode 100644 index 000000000..36c6b647d --- /dev/null +++ b/internal/cmd/kms/wrappingkey/list/list_test.go @@ -0,0 +1,252 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "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/kms" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &kms.APIClient{} + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + keyRingIdFlag: testKeyRingId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiListWrappingKeysRequest)) kms.ApiListWrappingKeysRequest { + request := testClient.ListWrappingKeys(testCtx, testProjectId, testRegion, testKeyRingId) + 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing keyRingId", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, keyRingIdFlag) + }), + isValid: false, + }, + { + description: "invalid keyRingId 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid keyRingId 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[keyRingIdFlag] = "Not an uuid" + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) + } + + 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) + } + + p := print.NewPrinter() + model, err := parseInput(p, 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(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiListWrappingKeysRequest + }{ + { + description: "base case", + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + keyRingId string + resp *kms.WrappingKeyList + outputFormat string + projectLabel string + wantErr bool + }{ + { + description: "nil response", + resp: nil, + projectLabel: "my-project", + wantErr: true, + }, + { + description: "default output", + resp: &kms.WrappingKeyList{WrappingKeys: &[]kms.WrappingKey{}}, + projectLabel: "my-project", + wantErr: false, + }, + { + description: "json output", + resp: &kms.WrappingKeyList{WrappingKeys: &[]kms.WrappingKey{}}, + outputFormat: print.JSONOutputFormat, + wantErr: false, + }, + { + description: "yaml output", + resp: &kms.WrappingKeyList{WrappingKeys: &[]kms.WrappingKey{}}, + outputFormat: print.YAMLOutputFormat, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.outputFormat, tt.keyRingId, tt.resp) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/kms/wrappingkey/wrappingkey.go b/internal/cmd/kms/wrappingkey/wrappingkey.go new file mode 100644 index 000000000..ab873566d --- /dev/null +++ b/internal/cmd/kms/wrappingkey/wrappingkey.go @@ -0,0 +1,32 @@ +package wrappingkey + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/wrappingkey/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/wrappingkey/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/wrappingkey/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms/wrappingkey/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "wrapping-key", + Short: "Manage KMS wrapping keys", + Long: "Provides functionality for wrapping key operations inside the KMS", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) +} diff --git a/internal/cmd/load-balancer/create/create.go b/internal/cmd/load-balancer/create/create.go index a0600f66d..47d479c8a 100644 --- a/internal/cmd/load-balancer/create/create.go +++ b/internal/cmd/load-balancer/create/create.go @@ -5,8 +5,13 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/uuid" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,8 +22,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer/wait" ) const ( @@ -34,7 +37,7 @@ var ( xRequestId = uuid.NewString() ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Load Balancer", @@ -57,31 +60,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ``, `$ stackit load-balancer create --payload @./payload.json`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a load balancer for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a load balancer for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -93,20 +94,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating load balancer") - _, err = wait.CreateLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *model.Payload.Name).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating load balancer", func() error { + _, err = wait.CreateLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *model.Payload.Name).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for load balancer creation: %w", err) } - s.Stop() } operationState := "Created" if model.Async { operationState = "Triggered creation of" } - p.Outputf("%s load balancer with name %q \n", operationState, utils.PtrString(model.Payload.Name)) + params.Printer.Outputf("%s load balancer with name %q \n", operationState, utils.PtrString(model.Payload.Name)) return nil }, } @@ -121,7 +122,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -142,15 +143,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Payload: payload, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/load-balancer/create/create_test.go b/internal/cmd/load-balancer/create/create_test.go index da7bfce83..ac22f6c6d 100644 --- a/internal/cmd/load-balancer/create/create_test.go +++ b/internal/cmd/load-balancer/create/create_test.go @@ -7,7 +7,7 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -35,7 +35,7 @@ var testPayload = &loadbalancer.CreateLoadBalancerPayload{ { DisplayName: utils.Ptr(""), Port: utils.Ptr(int64(0)), - Protocol: utils.Ptr(""), + Protocol: loadbalancer.ListenerProtocol("").Ptr(), ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ { Name: utils.Ptr(""), @@ -54,7 +54,7 @@ var testPayload = &loadbalancer.CreateLoadBalancerPayload{ Networks: &[]loadbalancer.Network{ { NetworkId: utils.Ptr(""), - Role: utils.Ptr(""), + Role: loadbalancer.NetworkRole("").Ptr(), }, }, Options: &loadbalancer.LoadBalancerOptions{ @@ -209,6 +209,7 @@ func fixtureRequest(mods ...func(request *loadbalancer.ApiCreateLoadBalancerRequ func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -276,56 +277,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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, - cmpopts.EquateComparable(testCtx), - ) - if diff != "" { - t.Fatalf("Data does not match: %s", diff) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/load-balancer/delete/delete.go b/internal/cmd/load-balancer/delete/delete.go index c2410db74..c60100607 100644 --- a/internal/cmd/load-balancer/delete/delete.go +++ b/internal/cmd/load-balancer/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -26,7 +28,7 @@ type inputModel struct { LoadBalancerName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", loadBalancerNameArg), Short: "Deletes a Load Balancer", @@ -39,22 +41,20 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete load balancer %q? (This cannot be undone)", model.LoadBalancerName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete load balancer %q? (This cannot be undone)", model.LoadBalancerName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -66,20 +66,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting load balancer") - _, err = wait.DeleteLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.LoadBalancerName).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting load balancer", func() error { + _, err = wait.DeleteLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.LoadBalancerName).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for load balancer deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s load balancer %q\n", operationState, model.LoadBalancerName) + params.Printer.Info("%s load balancer %q\n", operationState, model.LoadBalancerName) return nil }, } @@ -99,15 +99,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu LoadBalancerName: loadBalancerName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/load-balancer/delete/delete_test.go b/internal/cmd/load-balancer/delete/delete_test.go index 621113c56..61e9a941b 100644 --- a/internal/cmd/load-balancer/delete/delete_test.go +++ b/internal/cmd/load-balancer/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -129,54 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/load-balancer/describe/describe.go b/internal/cmd/load-balancer/describe/describe.go index 603bfd185..5cdffce6a 100644 --- a/internal/cmd/load-balancer/describe/describe.go +++ b/internal/cmd/load-balancer/describe/describe.go @@ -2,11 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +29,7 @@ type inputModel struct { LoadBalancerName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", loadBalancerNameArg), Short: "Shows details of a Load Balancer", @@ -45,12 +45,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -62,7 +62,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read load balancer: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -81,15 +81,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu LoadBalancerName: loadBalancerName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -102,47 +94,25 @@ func outputResult(p *print.Printer, outputFormat string, loadBalancer *loadbalan if loadBalancer == nil { return fmt.Errorf("loadbalancer response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(loadBalancer, "", " ") - if err != nil { - return fmt.Errorf("marshal load balancer: %w", err) + + return p.OutputResult(outputFormat, loadBalancer, func() error { + content := []tables.Table{} + content = append(content, buildLoadBalancerTable(loadBalancer)) + + if loadBalancer.Listeners != nil { + content = append(content, buildListenersTable(*loadBalancer.Listeners)) + } + if loadBalancer.TargetPools != nil { + content = append(content, buildTargetPoolsTable(*loadBalancer.TargetPools)) } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(loadBalancer, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + err := tables.DisplayTables(p, content) if err != nil { - return fmt.Errorf("marshal load balancer: %w", err) + return fmt.Errorf("display output: %w", err) } - p.Outputln(string(details)) return nil - default: - return outputResultAsTable(p, loadBalancer) - } -} - -func outputResultAsTable(p *print.Printer, loadBalancer *loadbalancer.LoadBalancer) error { - content := []tables.Table{} - - content = append(content, buildLoadBalancerTable(loadBalancer)) - - if loadBalancer.Listeners != nil { - content = append(content, buildListenersTable(*loadBalancer.Listeners)) - } - - if loadBalancer.TargetPools != nil { - content = append(content, buildTargetPoolsTable(*loadBalancer.TargetPools)) - } - - err := tables.DisplayTables(p, content) - if err != nil { - return fmt.Errorf("display output: %w", err) - } - - return nil + }) } func buildLoadBalancerTable(loadBalancer *loadbalancer.LoadBalancer) tables.Table { diff --git a/internal/cmd/load-balancer/describe/describe_test.go b/internal/cmd/load-balancer/describe/describe_test.go index 821f942b7..6254d9e93 100644 --- a/internal/cmd/load-balancer/describe/describe_test.go +++ b/internal/cmd/load-balancer/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) const ( @@ -128,54 +132,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -234,7 +191,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.loadBalancer); (err != nil) != tt.wantErr { diff --git a/internal/cmd/load-balancer/generate-payload/generate_payload.go b/internal/cmd/load-balancer/generate-payload/generate_payload.go index ba14de569..0170fcbe9 100644 --- a/internal/cmd/load-balancer/generate-payload/generate_payload.go +++ b/internal/cmd/load-balancer/generate-payload/generate_payload.go @@ -5,6 +5,10 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" "github.com/spf13/cobra" ) @@ -34,7 +37,7 @@ var ( defaultPayloadListener = &loadbalancer.Listener{ DisplayName: utils.Ptr(""), Port: utils.Ptr(int64(0)), - Protocol: utils.Ptr(""), + Protocol: loadbalancer.ListenerProtocol("").Ptr(), ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ { Name: utils.Ptr(""), @@ -51,7 +54,7 @@ var ( defaultPayloadNetwork = &loadbalancer.Network{ NetworkId: utils.Ptr(""), - Role: utils.Ptr(""), + Role: loadbalancer.NetworkRole("").Ptr(), } defaultPayloadTargetPool = &loadbalancer.TargetPool{ @@ -109,7 +112,7 @@ var ( } ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "generate-payload", Short: "Generates a payload to create/update a Load Balancer", @@ -133,22 +136,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `Generate a payload with values of an existing load balancer, and preview it in the terminal`, `$ stackit load-balancer generate-payload --lb-name xxx`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } if model.LoadBalancerName == nil { createPayload := DefaultCreateLoadBalancerPayload - return outputCreateResult(p, model.FilePath, &createPayload) + return outputCreateResult(params.Printer, model.FilePath, &createPayload) } req := buildRequest(ctx, model, apiClient) @@ -168,7 +171,7 @@ func NewCmd(p *print.Printer) *cobra.Command { TargetPools: resp.TargetPools, Version: resp.Version, } - return outputUpdateResult(p, model.FilePath, updatePayload) + return outputUpdateResult(params.Printer, model.FilePath, updatePayload) }, } configureFlags(cmd) @@ -180,7 +183,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given file. If unset, writes the payload to the standard output") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) loadBalancerName := flags.FlagToStringPointer(p, cmd, loadBalancerNameFlag) @@ -195,15 +198,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { FilePath: flags.FlagToStringPointer(p, cmd, filePathFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/load-balancer/generate-payload/generate_payload_test.go b/internal/cmd/load-balancer/generate-payload/generate_payload_test.go index 0e2ee4beb..1f0fefcdf 100644 --- a/internal/cmd/load-balancer/generate-payload/generate_payload_test.go +++ b/internal/cmd/load-balancer/generate-payload/generate_payload_test.go @@ -4,13 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) const ( @@ -68,6 +72,7 @@ func fixtureRequest(mods ...func(request *loadbalancer.ApiGetLoadBalancerRequest func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -131,54 +136,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -225,7 +183,7 @@ func TestModifyListeners(t *testing.T) { { DisplayName: utils.Ptr(""), Port: utils.Ptr(int64(0)), - Protocol: utils.Ptr(""), + Protocol: loadbalancer.ListenerProtocol("").Ptr(), Name: utils.Ptr(""), ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ { @@ -243,7 +201,7 @@ func TestModifyListeners(t *testing.T) { { DisplayName: utils.Ptr(""), Port: utils.Ptr(int64(0)), - Protocol: utils.Ptr(""), + Protocol: loadbalancer.ListenerProtocol("").Ptr(), Name: utils.Ptr(""), ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ { @@ -264,7 +222,7 @@ func TestModifyListeners(t *testing.T) { { DisplayName: utils.Ptr(""), Port: utils.Ptr(int64(0)), - Protocol: utils.Ptr(""), + Protocol: loadbalancer.ListenerProtocol("").Ptr(), Name: nil, ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ { @@ -282,7 +240,7 @@ func TestModifyListeners(t *testing.T) { { DisplayName: utils.Ptr(""), Port: utils.Ptr(int64(0)), - Protocol: utils.Ptr(""), + Protocol: loadbalancer.ListenerProtocol("").Ptr(), Name: nil, ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ { @@ -337,7 +295,7 @@ func TestOutputCreateResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputCreateResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr { @@ -371,7 +329,7 @@ func TestOutputUpdateResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputUpdateResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr { diff --git a/internal/cmd/load-balancer/list/list.go b/internal/cmd/load-balancer/list/list.go index a9611e137..3b14b5ca3 100644 --- a/internal/cmd/load-balancer/list/list.go +++ b/internal/cmd/load-balancer/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all Load Balancers", @@ -47,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 load balancers `, "$ stackit load-balancer list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,12 +68,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.LoadBalancers == nil || (resp.LoadBalancers != nil && len(*resp.LoadBalancers) == 0) { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No load balancers found for project %q\n", projectLabel) + params.Printer.Info("No load balancers found for project %q\n", projectLabel) return nil } @@ -83,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { loadBalancers = loadBalancers[:*model.Limit] } - return outputResult(p, model.OutputFormat, loadBalancers) + return outputResult(params.Printer, model.OutputFormat, loadBalancers) }, } @@ -95,7 +95,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -114,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -132,24 +124,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalance } func outputResult(p *print.Printer, outputFormat string, loadBalancers []loadbalancer.LoadBalancer) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(loadBalancers, "", " ") - if err != nil { - return fmt.Errorf("marshal load balancer list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(loadBalancers, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal load balancer list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, loadBalancers, func() error { table := tables.NewTable() table.SetHeader("NAME", "STATE", "IP ADDRESS", "LISTENERS", "TARGET POOLS") for i := range loadBalancers { @@ -177,5 +152,5 @@ func outputResult(p *print.Printer, outputFormat string, loadBalancers []loadbal } return nil - } + }) } diff --git a/internal/cmd/load-balancer/list/list_test.go b/internal/cmd/load-balancer/list/list_test.go index d0210b9cb..e400b0d6a 100644 --- a/internal/cmd/load-balancer/list/list_test.go +++ b/internal/cmd/load-balancer/list/list_test.go @@ -4,13 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) const ( @@ -61,6 +65,7 @@ func fixtureRequest(mods ...func(request *loadbalancer.ApiListLoadBalancersReque func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -115,46 +120,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -218,7 +184,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.loadBalancers); (err != nil) != tt.wantErr { diff --git a/internal/cmd/load-balancer/load_balancer.go b/internal/cmd/load-balancer/load_balancer.go index c6f6be2f5..25a8f34ae 100644 --- a/internal/cmd/load-balancer/load_balancer.go +++ b/internal/cmd/load-balancer/load_balancer.go @@ -10,15 +10,15 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/quota" targetpool "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool" "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "load-balancer", Aliases: []string{"lb"}, @@ -27,18 +27,18 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(generatepayload.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(quota.NewCmd(p)) - cmd.AddCommand(observabilitycredentials.NewCmd(p)) - cmd.AddCommand(targetpool.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(generatepayload.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(quota.NewCmd(params)) + cmd.AddCommand(observabilitycredentials.NewCmd(params)) + cmd.AddCommand(targetpool.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/load-balancer/observability-credentials/add/add.go b/internal/cmd/load-balancer/observability-credentials/add/add.go index a75b5e064..89435c3fc 100644 --- a/internal/cmd/load-balancer/observability-credentials/add/add.go +++ b/internal/cmd/load-balancer/observability-credentials/add/add.go @@ -2,11 +2,12 @@ package add import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -34,7 +35,7 @@ type inputModel struct { Password *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "Adds observability credentials to Load Balancer", @@ -48,40 +49,38 @@ func NewCmd(p *print.Printer) *cobra.Command { `Add observability credentials to a load balancer with username "xxx" and display name "yyy", providing the path to a file with the password as flag`, "$ stackit load-balancer observability-credentials add --username xxx --password @./password.txt --display-name yyy"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } // Prompt for password if not passed in as a flag if model.Password == nil { - pwd, err := p.PromptForPassword("Enter user password: ") + pwd, err := params.Printer.PromptForPassword("Enter user password: ") if err != nil { return fmt.Errorf("prompt for password: %w", err) } model.Password = utils.Ptr(pwd) } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to add observability credentials for Load Balancer on project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to add observability credentials for Load Balancer on project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -91,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("add Load Balancer observability credentials: %w", err) } - return outputResult(p, model.OutputFormat, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) }, } configureFlags(cmd) @@ -107,7 +106,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -120,15 +119,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Password: flags.FlagToStringPointer(p, cmd, passwordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -149,25 +140,8 @@ func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *loa return fmt.Errorf("nil observability credentials response") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal Load Balancer observability credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Load Balancer observability credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Added Load Balancer observability credentials on project %q. Credentials reference: %q\n", projectLabel, utils.PtrString(resp.Credential.CredentialsRef)) return nil - } + }) } diff --git a/internal/cmd/load-balancer/observability-credentials/add/add_test.go b/internal/cmd/load-balancer/observability-credentials/add/add_test.go index 57557613a..d5b1fe7c8 100644 --- a/internal/cmd/load-balancer/observability-credentials/add/add_test.go +++ b/internal/cmd/load-balancer/observability-credentials/add/add_test.go @@ -4,13 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) const ( @@ -70,6 +74,7 @@ func fixtureRequest(mods ...func(request *loadbalancer.ApiCreateCredentialsReque func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -124,46 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -188,7 +154,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), - cmpopts.IgnoreFields(loadbalancer.ApiCreateCredentialsRequest{}, "xRequestID"), + cmpopts.IgnoreFields(loadbalancer.CreateCredentialsRequest{}, "xRequestID"), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -229,7 +195,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup.go b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup.go index 28b560b2a..35e123ee3 100644 --- a/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup.go +++ b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -21,7 +23,7 @@ type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "cleanup", Short: "Deletes observability credentials unused by any Load Balancer", @@ -32,22 +34,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `Delete observability credentials unused by any Load Balancer`, "$ stackit load-balancer observability-credentials cleanup"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } @@ -66,25 +68,23 @@ func NewCmd(p *print.Printer) *cobra.Command { } if len(credentials) == 0 { - p.Info("No unused observability credentials found on project %q\n", projectLabel) + params.Printer.Info("No unused observability credentials found on project %q\n", projectLabel) return nil } - if !model.AssumeYes { - prompt := "Will delete the following unused observability credentials: \n" - for _, credential := range credentials { - if credential.DisplayName == nil || credential.Username == nil { - return fmt.Errorf("list unused Load Balancer observability credentials: credentials %q missing display name or username", *credential.CredentialsRef) - } - name := *credential.DisplayName - username := *credential.Username - prompt += fmt.Sprintf(" - %s (username: %q)\n", name, username) - } - prompt += fmt.Sprintf("Are you sure you want to delete unused observability credentials on project %q? (This cannot be undone)", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err + prompt := "Will delete the following unused observability credentials: \n" + for _, credential := range credentials { + if credential.DisplayName == nil || credential.Username == nil { + return fmt.Errorf("list unused Load Balancer observability credentials: credentials %q missing display name or username", *credential.CredentialsRef) } + name := *credential.DisplayName + username := *credential.Username + prompt += fmt.Sprintf(" - %s (username: %q)\n", name, username) + } + prompt += fmt.Sprintf("Are you sure you want to delete unused observability credentials on project %q? (This cannot be undone)", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } for _, credential := range credentials { @@ -100,14 +100,14 @@ func NewCmd(p *print.Printer) *cobra.Command { } } - p.Info("Deleted unused Load Balancer observability credentials on project %q\n", projectLabel) + params.Printer.Info("Deleted unused Load Balancer observability credentials on project %q\n", projectLabel) return nil }, } return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -117,15 +117,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go index ae7b8e3c4..a081af49c 100644 --- a/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go +++ b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go @@ -4,10 +4,11 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" @@ -115,54 +116,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/load-balancer/observability-credentials/delete/delete.go b/internal/cmd/load-balancer/observability-credentials/delete/delete.go index 73c9db46d..4139f56db 100644 --- a/internal/cmd/load-balancer/observability-credentials/delete/delete.go +++ b/internal/cmd/load-balancer/observability-credentials/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -26,7 +28,7 @@ type inputModel struct { CredentialsRef string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialsRefArg), Short: "Deletes observability credentials for Load Balancer", @@ -39,35 +41,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } credentialsLabel, err := loadbalancerUtils.GetCredentialsDisplayName(ctx, apiClient, model.ProjectId, model.Region, model.CredentialsRef) if err != nil { - p.Debug(print.ErrorLevel, "get observability credentials display name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get observability credentials display name: %v", err) credentialsLabel = model.CredentialsRef } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete observability credentials %q on project %q?(This cannot be undone)", credentialsLabel, projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete observability credentials %q on project %q?(This cannot be undone)", credentialsLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -77,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Load Balancer observability credentials: %w", err) } - p.Info("Deleted observability credentials %q on project %q\n", credentialsLabel, projectLabel) + params.Printer.Info("Deleted observability credentials %q on project %q\n", credentialsLabel, projectLabel) return nil }, } @@ -97,15 +97,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsRef: credentialsRef, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/load-balancer/observability-credentials/delete/delete_test.go b/internal/cmd/load-balancer/observability-credentials/delete/delete_test.go index d4f22f799..3a8675d10 100644 --- a/internal/cmd/load-balancer/observability-credentials/delete/delete_test.go +++ b/internal/cmd/load-balancer/observability-credentials/delete/delete_test.go @@ -4,10 +4,11 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" @@ -135,54 +136,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/load-balancer/observability-credentials/describe/describe.go b/internal/cmd/load-balancer/observability-credentials/describe/describe.go index 9f622f750..68a4af99c 100644 --- a/internal/cmd/load-balancer/observability-credentials/describe/describe.go +++ b/internal/cmd/load-balancer/observability-credentials/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +28,7 @@ type inputModel struct { CredentialsRef string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", credentialsRefArg), Short: "Shows details of observability credentials for Load Balancer", @@ -41,13 +41,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -59,7 +59,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe Load Balancer observability credentials: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -78,15 +78,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsRef: credentialsRef, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -96,24 +88,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalance } func outputResult(p *print.Printer, outputFormat string, credentials *loadbalancer.GetCredentialsResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal Load Balancer observability credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Load Balancer observability credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, credentials, func() error { if credentials == nil || credentials.Credential == nil { return fmt.Errorf("credentials response is empty") } @@ -131,5 +106,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials *loadbalanc } return nil - } + }) } diff --git a/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go b/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go index a67979219..e553e0e3a 100644 --- a/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go +++ b/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) const ( @@ -134,54 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -245,7 +202,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/load-balancer/observability-credentials/list/list.go b/internal/cmd/load-balancer/observability-credentials/list/list.go index 87e149053..740c94b98 100644 --- a/internal/cmd/load-balancer/observability-credentials/list/list.go +++ b/internal/cmd/load-balancer/observability-credentials/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -18,7 +20,6 @@ import ( lbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) const ( @@ -35,7 +36,7 @@ type inputModel struct { Unused bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists observability credentials for Load Balancer", @@ -58,22 +59,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 Load Balancer observability credentials`, "$ stackit load-balancer observability-credentials list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } @@ -105,7 +106,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } else if model.Unused { opLabel += "unused" } - p.Info("%s observability credentials found for Load Balancer on project %q\n", opLabel, projectLabel) + params.Printer.Info("%s observability credentials found for Load Balancer on project %q\n", opLabel, projectLabel) return nil } @@ -113,7 +114,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentials) + return outputResult(params.Printer, model.OutputFormat, credentials) }, } configureFlags(cmd) @@ -128,7 +129,7 @@ func configureFlags(cmd *cobra.Command) { cmd.MarkFlagsMutuallyExclusive(usedFlag, unusedFlag) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -149,15 +150,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Unused: flags.FlagToBoolValue(p, cmd, unusedFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -167,24 +160,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalance } func outputResult(p *print.Printer, outputFormat string, credentials []loadbalancer.CredentialsResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal Load Balancer observability credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Load Balancer observability credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, credentials, func() error { table := tables.NewTable() table.SetHeader("REFERENCE", "DISPLAY NAME", "USERNAME") for i := range credentials { @@ -197,7 +173,7 @@ func outputResult(p *print.Printer, outputFormat string, credentials []loadbalan } return nil - } + }) } func getFilterOp(used, unused bool) (int, error) { diff --git a/internal/cmd/load-balancer/observability-credentials/list/list_test.go b/internal/cmd/load-balancer/observability-credentials/list/list_test.go index ddf04272e..c5ad1d770 100644 --- a/internal/cmd/load-balancer/observability-credentials/list/list_test.go +++ b/internal/cmd/load-balancer/observability-credentials/list/list_test.go @@ -4,14 +4,18 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" lbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) const ( @@ -62,6 +66,7 @@ func fixtureRequest(mods ...func(request *loadbalancer.ApiListCredentialsRequest func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -144,54 +149,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -297,7 +255,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/load-balancer/observability-credentials/observability-credentials.go b/internal/cmd/load-balancer/observability-credentials/observability-credentials.go index 390b83caf..7abc80f62 100644 --- a/internal/cmd/load-balancer/observability-credentials/observability-credentials.go +++ b/internal/cmd/load-balancer/observability-credentials/observability-credentials.go @@ -8,13 +8,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/list" "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/observability-credentials/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "observability-credentials", Short: "Provides functionality for Load Balancer observability credentials", @@ -23,15 +23,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Aliases: []string{"credentials"}, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(add.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(cleanup.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(add.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(cleanup.NewCmd(params)) } diff --git a/internal/cmd/load-balancer/observability-credentials/update/update.go b/internal/cmd/load-balancer/observability-credentials/update/update.go index 4c76da9f9..f19afb714 100644 --- a/internal/cmd/load-balancer/observability-credentials/update/update.go +++ b/internal/cmd/load-balancer/observability-credentials/update/update.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -45,7 +47,7 @@ type inputModel struct { Password *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", credentialsRefArg), Short: "Updates observability credentials for Load Balancer", @@ -61,44 +63,42 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } credentialsLabel, err := loadBalancerUtils.GetCredentialsDisplayName(ctx, apiClient, model.ProjectId, model.Region, model.CredentialsRef) if err != nil { - p.Debug(print.ErrorLevel, "get credentials display name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials display name: %v", err) credentialsLabel = model.CredentialsRef } // Prompt for password if not passed in as a flag if model.Password == nil { - pwd, err := p.PromptForPassword("Enter new password: ") + pwd, err := params.Printer.PromptForPassword("Enter new password: ") if err != nil { return fmt.Errorf("prompt for password: %w", err) } model.Password = utils.Ptr(pwd) } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update observability credentials %q for Load Balancer on project %q?", credentialsLabel, projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update observability credentials %q for Load Balancer on project %q?", credentialsLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -112,7 +112,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update Load Balancer observability credentials: %w", err) } - p.Info("Updated observability credentials %q for Load Balancer on project %q\n", credentialsLabel, projectLabel) + params.Printer.Info("Updated observability credentials %q for Load Balancer on project %q\n", credentialsLabel, projectLabel) return nil }, } diff --git a/internal/cmd/load-balancer/observability-credentials/update/update_test.go b/internal/cmd/load-balancer/observability-credentials/update/update_test.go index 87677fa86..563b489d6 100644 --- a/internal/cmd/load-balancer/observability-credentials/update/update_test.go +++ b/internal/cmd/load-balancer/observability-credentials/update/update_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -179,54 +179,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/load-balancer/quota/quota.go b/internal/cmd/load-balancer/quota/quota.go index 9c2fb2840..9539612f9 100644 --- a/internal/cmd/load-balancer/quota/quota.go +++ b/internal/cmd/load-balancer/quota/quota.go @@ -2,11 +2,11 @@ package quota import ( "context" - "encoding/json" "fmt" "strconv" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -22,7 +22,7 @@ type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "quota", Short: "Shows the configured Load Balancer quota", @@ -33,14 +33,14 @@ func NewCmd(p *print.Printer) *cobra.Command { `Get the configured load balancer quota for the project`, "$ stackit load-balancer quota"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -52,13 +52,13 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get load balancer quota: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -68,15 +68,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -89,9 +81,8 @@ func outputResult(p *print.Printer, outputFormat string, quota *loadbalancer.Get if quota == nil { return fmt.Errorf("quota response is empty") } - switch outputFormat { - case print.PrettyOutputFormat: + return p.OutputResult(outputFormat, quota, func() error { maxLoadBalancers := "Unlimited" if quota.MaxLoadBalancers != nil && *quota.MaxLoadBalancers != -1 { maxLoadBalancers = strconv.FormatInt(*quota.MaxLoadBalancers, 10) @@ -100,22 +91,5 @@ func outputResult(p *print.Printer, outputFormat string, quota *loadbalancer.Get p.Outputf("Maximum number of load balancers allowed: %s\n", maxLoadBalancers) return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(quota, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal quota: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - details, err := json.MarshalIndent(quota, "", " ") - if err != nil { - return fmt.Errorf("marshal quota: %w", err) - } - - p.Outputln(string(details)) - - return nil - } + }) } diff --git a/internal/cmd/load-balancer/quota/quota_test.go b/internal/cmd/load-balancer/quota/quota_test.go index e1ba5a893..bee2dc9dc 100644 --- a/internal/cmd/load-balancer/quota/quota_test.go +++ b/internal/cmd/load-balancer/quota/quota_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) const ( @@ -58,6 +62,7 @@ func fixtureRequest(mods ...func(request *loadbalancer.ApiGetQuotaRequest)) load func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -93,46 +98,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -194,7 +160,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.quota); (err != nil) != tt.wantErr { diff --git a/internal/cmd/load-balancer/target-pool/add-target/add_target.go b/internal/cmd/load-balancer/target-pool/add-target/add_target.go index 139dfda26..716c273a3 100644 --- a/internal/cmd/load-balancer/target-pool/add-target/add_target.go +++ b/internal/cmd/load-balancer/target-pool/add-target/add_target.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -12,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" "github.com/spf13/cobra" ) @@ -34,7 +37,7 @@ type inputModel struct { IP string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("add-target %s", ipArg), Short: "Adds a target to a target pool", @@ -49,23 +52,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to add a target with IP %q to target pool %q of load balancer %q?", model.IP, model.TargetPoolName, model.LBName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to add a target with IP %q to target pool %q of load balancer %q?", model.IP, model.TargetPoolName, model.LBName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("add target to target pool: %w", err) } - p.Info("Added target to target pool of load balancer %q\n", model.LBName) + params.Printer.Info("Added target to target pool of load balancer %q\n", model.LBName) return nil }, } @@ -111,15 +112,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu IP: ip, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go b/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go index 6fd3da6c9..c4fec49c0 100644 --- a/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go +++ b/internal/cmd/load-balancer/target-pool/add-target/add_target_test.go @@ -5,10 +5,13 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -260,7 +263,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { @@ -337,7 +340,7 @@ func TestBuildRequest(t *testing.T) { }, } }) - *request = request.UpdateTargetPoolPayload(*payload) + *request = (*request).UpdateTargetPoolPayload(*payload) }), }, { @@ -356,7 +359,7 @@ func TestBuildRequest(t *testing.T) { }, } }) - *request = request.UpdateTargetPoolPayload(*payload) + *request = (*request).UpdateTargetPoolPayload(*payload) }), }, { @@ -375,7 +378,7 @@ func TestBuildRequest(t *testing.T) { }, } }) - *request = request.UpdateTargetPoolPayload(*payload) + *request = (*request).UpdateTargetPoolPayload(*payload) }), }, { diff --git a/internal/cmd/load-balancer/target-pool/describe/describe.go b/internal/cmd/load-balancer/target-pool/describe/describe.go index a4ffa6d65..99b267962 100644 --- a/internal/cmd/load-balancer/target-pool/describe/describe.go +++ b/internal/cmd/load-balancer/target-pool/describe/describe.go @@ -2,13 +2,15 @@ package describe import ( "context" - "encoding/json" "fmt" "strconv" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -19,7 +21,6 @@ import ( lbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) const ( @@ -34,7 +35,7 @@ type inputModel struct { LBName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", targetPoolNameArg), Short: "Shows details of a target pool in a Load Balancer", @@ -50,12 +51,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -74,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command { listener := lbUtils.FindLoadBalancerListenerByTargetPool(*resp.Listeners, *targetPool.Name) - return outputResult(p, model.OutputFormat, *targetPool, listener) + return outputResult(params.Printer, model.OutputFormat, *targetPool, listener) }, } configureFlags(cmd) @@ -102,15 +103,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu LBName: cmd.Flag(lbNameFlag).Value.String(), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -131,90 +124,69 @@ func outputResult(p *print.Printer, outputFormat string, targetPool loadbalancer listener, } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("marshal load balancer: %w", err) + return p.OutputResult(outputFormat, output, func() error { + sessionPersistence := "None" + if targetPool.SessionPersistence != nil && targetPool.SessionPersistence.UseSourceIpAddress != nil && *targetPool.SessionPersistence.UseSourceIpAddress { + sessionPersistence = "Use Source IP" } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(output, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal load balancer: %w", err) + healthCheckInterval := "-" + healthCheckUnhealthyThreshold := "-" + healthCheckHealthyThreshold := "-" + if targetPool.ActiveHealthCheck != nil { + if targetPool.ActiveHealthCheck.Interval != nil { + healthCheckInterval = *targetPool.ActiveHealthCheck.Interval + } + if targetPool.ActiveHealthCheck.UnhealthyThreshold != nil { + healthCheckUnhealthyThreshold = strconv.FormatInt(*targetPool.ActiveHealthCheck.UnhealthyThreshold, 10) + } + if targetPool.ActiveHealthCheck.HealthyThreshold != nil { + healthCheckHealthyThreshold = strconv.FormatInt(*targetPool.ActiveHealthCheck.HealthyThreshold, 10) + } } - p.Outputln(string(details)) - return nil - default: - return outputResultAsTable(p, targetPool, listener) - } -} - -func outputResultAsTable(p *print.Printer, targetPool loadbalancer.TargetPool, listener *loadbalancer.Listener) error { - sessionPersistence := "None" - if targetPool.SessionPersistence != nil && targetPool.SessionPersistence.UseSourceIpAddress != nil && *targetPool.SessionPersistence.UseSourceIpAddress { - sessionPersistence = "Use Source IP" - } - - healthCheckInterval := "-" - healthCheckUnhealthyThreshold := "-" - healthCheckHealthyThreshold := "-" - if targetPool.ActiveHealthCheck != nil { - if targetPool.ActiveHealthCheck.Interval != nil { - healthCheckInterval = *targetPool.ActiveHealthCheck.Interval - } - if targetPool.ActiveHealthCheck.UnhealthyThreshold != nil { - healthCheckUnhealthyThreshold = strconv.FormatInt(*targetPool.ActiveHealthCheck.UnhealthyThreshold, 10) - } - if targetPool.ActiveHealthCheck.HealthyThreshold != nil { - healthCheckHealthyThreshold = strconv.FormatInt(*targetPool.ActiveHealthCheck.HealthyThreshold, 10) + targets := "-" + if targetPool.Targets != nil { + var targetsSlice []string + for _, target := range *targetPool.Targets { + targetStr := fmt.Sprintf("%s (%s)", *target.DisplayName, *target.Ip) + targetsSlice = append(targetsSlice, targetStr) + } + targets = strings.Join(targetsSlice, "\n") } - } - targets := "-" - if targetPool.Targets != nil { - var targetsSlice []string - for _, target := range *targetPool.Targets { - targetStr := fmt.Sprintf("%s (%s)", *target.DisplayName, *target.Ip) - targetsSlice = append(targetsSlice, targetStr) + listenerStr := "-" + if listener != nil { + listenerStr = fmt.Sprintf("%s (Port:%s, Protocol: %s)", + utils.PtrString(listener.Name), + utils.PtrString(listener.Port), + utils.PtrString(listener.Protocol), + ) } - targets = strings.Join(targetsSlice, "\n") - } - - listenerStr := "-" - if listener != nil { - listenerStr = fmt.Sprintf("%s (Port:%s, Protocol: %s)", - utils.PtrString(listener.Name), - utils.PtrString(listener.Port), - utils.PtrString(listener.Protocol), - ) - } - table := tables.NewTable() - table.AddRow("NAME", utils.PtrString(targetPool.Name)) - table.AddSeparator() - table.AddRow("TARGET PORT", utils.PtrString(targetPool.TargetPort)) - table.AddSeparator() - table.AddRow("ATTACHED LISTENER", listenerStr) - table.AddSeparator() - table.AddRow("TARGETS", targets) - table.AddSeparator() - table.AddRow("SESSION PERSISTENCE", sessionPersistence) - table.AddSeparator() - table.AddRow("HEALTH CHECK INTERVAL", healthCheckInterval) - table.AddSeparator() - table.AddRow("HEALTH CHECK DOWN AFTER", healthCheckUnhealthyThreshold) - table.AddSeparator() - table.AddRow("HEALTH CHECK UP AFTER", healthCheckHealthyThreshold) - table.AddSeparator() - - err := p.PagerDisplay(table.Render()) - if err != nil { - return fmt.Errorf("display output: %w", err) - } + table := tables.NewTable() + table.AddRow("NAME", utils.PtrString(targetPool.Name)) + table.AddSeparator() + table.AddRow("TARGET PORT", utils.PtrString(targetPool.TargetPort)) + table.AddSeparator() + table.AddRow("ATTACHED LISTENER", listenerStr) + table.AddSeparator() + table.AddRow("TARGETS", targets) + table.AddSeparator() + table.AddRow("SESSION PERSISTENCE", sessionPersistence) + table.AddSeparator() + table.AddRow("HEALTH CHECK INTERVAL", healthCheckInterval) + table.AddSeparator() + table.AddRow("HEALTH CHECK DOWN AFTER", healthCheckUnhealthyThreshold) + table.AddSeparator() + table.AddRow("HEALTH CHECK UP AFTER", healthCheckHealthyThreshold) + table.AddSeparator() + + err := p.PagerDisplay(table.Render()) + if err != nil { + return fmt.Errorf("display output: %w", err) + } - return nil + return nil + }) } diff --git a/internal/cmd/load-balancer/target-pool/describe/describe_test.go b/internal/cmd/load-balancer/target-pool/describe/describe_test.go index e1acadb53..f16e3deaf 100644 --- a/internal/cmd/load-balancer/target-pool/describe/describe_test.go +++ b/internal/cmd/load-balancer/target-pool/describe/describe_test.go @@ -4,12 +4,15 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) type testCtxKey struct{} @@ -136,7 +139,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { @@ -244,7 +247,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.targetPool, tt.args.listener); (err != nil) != tt.wantErr { diff --git a/internal/cmd/load-balancer/target-pool/remove-target/remove_target.go b/internal/cmd/load-balancer/target-pool/remove-target/remove_target.go index 2fbb0c447..129ca8773 100644 --- a/internal/cmd/load-balancer/target-pool/remove-target/remove_target.go +++ b/internal/cmd/load-balancer/target-pool/remove-target/remove_target.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -12,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client" "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" "github.com/spf13/cobra" ) @@ -32,7 +35,7 @@ type inputModel struct { IP string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("remove-target %s", ipArg), Short: "Removes a target from a target pool", @@ -45,29 +48,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } targetLabel, err := utils.GetTargetName(ctx, apiClient, model.ProjectId, model.Region, model.LBName, model.TargetPoolName, model.IP) if err != nil { - p.Debug(print.ErrorLevel, "get target name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get target name: %v", err) targetLabel = model.IP } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to remove target %q from target pool %q of load balancer %q?", targetLabel, model.TargetPoolName, model.LBName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to remove target %q from target pool %q of load balancer %q?", targetLabel, model.TargetPoolName, model.LBName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -80,7 +81,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("remove target from target pool: %w", err) } - p.Info("Removed target from target pool of load balancer %q\n", model.LBName) + params.Printer.Info("Removed target from target pool of load balancer %q\n", model.LBName) return nil }, } @@ -111,15 +112,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu IP: ip, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go b/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go index e961447d2..57d47df6f 100644 --- a/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go +++ b/internal/cmd/load-balancer/target-pool/remove-target/remove_target_test.go @@ -5,10 +5,13 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -249,7 +252,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { @@ -319,7 +322,7 @@ func TestBuildRequest(t *testing.T) { payload := fixturePayload(func(payload *loadbalancer.UpdateTargetPoolPayload) { payload.Targets = utils.Ptr((*payload.Targets)[1:]) }) - *request = request.UpdateTargetPoolPayload(*payload) + *request = (*request).UpdateTargetPoolPayload(*payload) }), }, { diff --git a/internal/cmd/load-balancer/target-pool/target_pool.go b/internal/cmd/load-balancer/target-pool/target_pool.go index 78a8d50c7..7e40f76e7 100644 --- a/internal/cmd/load-balancer/target-pool/target_pool.go +++ b/internal/cmd/load-balancer/target-pool/target_pool.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool/describe" removetarget "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer/target-pool/remove-target" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "target-pool", Short: "Provides functionality for target pools", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(addtarget.NewCmd(p)) - cmd.AddCommand(removetarget.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(addtarget.NewCmd(params)) + cmd.AddCommand(removetarget.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) } diff --git a/internal/cmd/load-balancer/update/update.go b/internal/cmd/load-balancer/update/update.go index 154e9d992..5e55a414b 100644 --- a/internal/cmd/load-balancer/update/update.go +++ b/internal/cmd/load-balancer/update/update.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { Payload loadbalancer.UpdateLoadBalancerPayload } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", loadBalancerNameArg), Short: "Updates a Load Balancer", @@ -53,23 +55,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update load balancer %q?", model.LoadBalancerName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update load balancer %q?", model.LoadBalancerName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -80,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } // The API has no status to wait on, so async mode is default - p.Info("Updated load balancer with name %q\n", model.LoadBalancerName) + params.Printer.Info("Updated load balancer with name %q\n", model.LoadBalancerName) return nil }, } @@ -116,15 +116,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Payload: payload, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/load-balancer/update/update_test.go b/internal/cmd/load-balancer/update/update_test.go index b6c998138..23504dcfd 100644 --- a/internal/cmd/load-balancer/update/update_test.go +++ b/internal/cmd/load-balancer/update/update_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -32,7 +32,7 @@ var testPayload = loadbalancer.UpdateLoadBalancerPayload{ { DisplayName: utils.Ptr(""), Port: utils.Ptr(int64(0)), - Protocol: utils.Ptr(""), + Protocol: loadbalancer.ListenerProtocol("").Ptr(), ServerNameIndicators: &[]loadbalancer.ServerNameIndicator{ { Name: utils.Ptr(""), @@ -51,7 +51,7 @@ var testPayload = loadbalancer.UpdateLoadBalancerPayload{ Networks: &[]loadbalancer.Network{ { NetworkId: utils.Ptr(""), - Role: utils.Ptr(""), + Role: loadbalancer.NetworkRole("").Ptr(), }, }, Options: &loadbalancer.LoadBalancerOptions{ @@ -299,54 +299,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/logme/credentials/create/create.go b/internal/cmd/logme/credentials/create/create.go index 61df00f15..aab7a20ae 100644 --- a/internal/cmd/logme/credentials/create/create.go +++ b/internal/cmd/logme/credentials/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logme" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/client" logmeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/logme" ) const ( @@ -30,7 +31,7 @@ type inputModel struct { ShowPassword bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates credentials for a LogMe instance", @@ -44,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create credentials for a LogMe instance and show the password in the output`, "$ stackit logme credentials create --instance-id xxx --show-password"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := logmeUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create LogMe credentials: %w", err) } - return outputResult(p, model.OutputFormat, model.ShowPassword, instanceLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.ShowPassword, instanceLabel, resp) }, } configureFlags(cmd) @@ -93,7 +92,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -105,15 +104,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ShowPassword: flags.FlagToBoolValue(p, cmd, showPasswordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -130,24 +121,8 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, inst if !showPassword && resp.HasRaw() && resp.Raw.Credentials != nil { resp.Raw.Credentials.Password = utils.Ptr("hidden") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal LogMe credentials: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal LogMe credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, utils.PtrString(resp.Id)) // The username field cannot be set by the user so we only display it if it's not returned empty if resp.HasRaw() && resp.Raw.Credentials != nil { @@ -164,5 +139,5 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, inst } p.Outputf("URI: %s\n", utils.PtrString(resp.Uri)) return nil - } + }) } diff --git a/internal/cmd/logme/credentials/create/create_test.go b/internal/cmd/logme/credentials/create/create_test.go index 3ad8aec16..b2ecffe1f 100644 --- a/internal/cmd/logme/credentials/create/create_test.go +++ b/internal/cmd/logme/credentials/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -58,6 +61,7 @@ func fixtureRequest(mods ...func(request *logme.ApiCreateCredentialsRequest)) lo func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -129,46 +133,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -227,7 +192,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.showPassword, tt.args.instanceLabel, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/logme/credentials/credentials.go b/internal/cmd/logme/credentials/credentials.go index 1b884797a..51f821a68 100644 --- a/internal/cmd/logme/credentials/credentials.go +++ b/internal/cmd/logme/credentials/credentials.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/logme/credentials/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/logme/credentials/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", Short: "Provides functionality for LogMe credentials", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/logme/credentials/delete/delete.go b/internal/cmd/logme/credentials/delete/delete.go index 2f884ac0e..26d2750fd 100644 --- a/internal/cmd/logme/credentials/delete/delete.go +++ b/internal/cmd/logme/credentials/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialsIdArg), Short: "Deletes credentials of a LogMe instance", @@ -43,35 +45,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := logmeUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } credentialsLabel, err := logmeUtils.GetCredentialsUsername(ctx, apiClient, model.ProjectId, model.InstanceId, model.CredentialsId) if err != nil { - p.Debug(print.ErrorLevel, "get credentials username: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials username: %v", err) credentialsLabel = model.CredentialsId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,7 +81,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete LogMe credentials: %w", err) } - p.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) + params.Printer.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) return nil }, } @@ -110,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/logme/credentials/delete/delete_test.go b/internal/cmd/logme/credentials/delete/delete_test.go index ea6d637f2..466250e72 100644 --- a/internal/cmd/logme/credentials/delete/delete_test.go +++ b/internal/cmd/logme/credentials/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -164,54 +164,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/logme/credentials/describe/describe.go b/internal/cmd/logme/credentials/describe/describe.go index 2a73c9a15..670b064c0 100644 --- a/internal/cmd/logme/credentials/describe/describe.go +++ b/internal/cmd/logme/credentials/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", credentialsIdArg), Short: "Shows details of credentials of a LogMe instance", @@ -48,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe LogMe credentials: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -94,15 +94,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -116,24 +108,7 @@ func outputResult(p *print.Printer, outputFormat string, credentials *logme.Cred return fmt.Errorf("credentials is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal LogMe credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal LogMe credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, credentials, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(credentials.Id)) table.AddSeparator() @@ -155,5 +130,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials *logme.Cred } return nil - } + }) } diff --git a/internal/cmd/logme/credentials/describe/describe_test.go b/internal/cmd/logme/credentials/describe/describe_test.go index 3ef166834..c2a4c9125 100644 --- a/internal/cmd/logme/credentials/describe/describe_test.go +++ b/internal/cmd/logme/credentials/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -164,54 +167,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -268,7 +224,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/logme/credentials/list/list.go b/internal/cmd/logme/credentials/list/list.go index a149a6090..ac2660747 100644 --- a/internal/cmd/logme/credentials/list/list.go +++ b/internal/cmd/logme/credentials/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all credentials' IDs for a LogMe instance", @@ -49,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 credentials' IDs for a LogMe instance`, "$ stackit logme credentials list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,22 +68,19 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list LogMe credentials: %w", err) } - credentials := *resp.CredentialsList - if len(credentials) == 0 { - instanceLabel, err := logmeUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) - if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) - instanceLabel = model.InstanceId - } - p.Info("No credentials found for instance %q\n", instanceLabel) - return nil + credentials := resp.GetCredentialsList() + + instanceLabel, err := logmeUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId } // Truncate output if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentials) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, credentials) }, } configureFlags(cmd) @@ -98,7 +95,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -118,15 +115,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -135,25 +124,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *logme.APICl return req } -func outputResult(p *print.Printer, outputFormat string, credentials []logme.CredentialsListItem) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal LogMe credentials list: %w", err) +func outputResult(p *print.Printer, outputFormat, instanceLabel string, credentials []logme.CredentialsListItem) error { + return p.OutputResult(outputFormat, credentials, func() error { + if len(credentials) == 0 { + p.Outputf("No credentials found for instance %q\n", instanceLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal LogMe credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID") for i := range credentials { @@ -166,5 +143,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials []logme.Cre } return nil - } + }) } diff --git a/internal/cmd/logme/credentials/list/list_test.go b/internal/cmd/logme/credentials/list/list_test.go index dd9b71e66..30926c183 100644 --- a/internal/cmd/logme/credentials/list/list_test.go +++ b/internal/cmd/logme/credentials/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/logme" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,9 +26,9 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -61,6 +62,7 @@ func fixtureRequest(mods ...func(request *logme.ApiListCredentialsRequest)) logm func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -79,21 +81,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,46 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -210,8 +173,9 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { - outputFormat string - credentials []logme.CredentialsListItem + outputFormat string + instanceLabel string + credentials []logme.CredentialsListItem } tests := []struct { name string @@ -239,10 +203,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.credentials); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/logme/instance/create/create.go b/internal/cmd/logme/instance/create/create.go index df11b20a2..367c9cc60 100644 --- a/internal/cmd/logme/instance/create/create.go +++ b/internal/cmd/logme/instance/create/create.go @@ -2,12 +2,12 @@ package create import ( "context" - "encoding/json" "errors" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -55,7 +55,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a LogMe instance", @@ -72,31 +72,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a LogMe instance with name "my-instance" and specify IP range which is allowed to access it`, "$ stackit logme instance create --name my-instance --plan-id xxx --acl 1.2.3.0/24"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a LogMe instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a LogMe instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -116,16 +114,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for LogMe instance creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) }, } configureFlags(cmd) @@ -149,7 +147,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -185,15 +183,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -256,29 +246,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, projectLabe return fmt.Errorf("response is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal LogMe instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal LogMe instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, utils.PtrString(resp.InstanceId)) return nil - } + }) } diff --git a/internal/cmd/logme/instance/create/create_test.go b/internal/cmd/logme/instance/create/create_test.go index ac8928edf..73558ab91 100644 --- a/internal/cmd/logme/instance/create/create_test.go +++ b/internal/cmd/logme/instance/create/create_test.go @@ -5,8 +5,11 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -107,6 +110,7 @@ func fixtureRequest(mods ...func(request *logme.ApiCreateInstanceRequest)) logme func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string sgwAclValues []string syslogValues []string @@ -261,66 +265,10 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.sgwAclValues { - err := cmd.Flags().Set(sgwAclFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) - } - } - - for _, value := range tt.syslogValues { - err := cmd.Flags().Set(syslogFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) - } - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + sgwAclFlag: tt.sgwAclValues, + syslogFlag: tt.syslogValues, + }, tt.isValid) }) } } @@ -490,7 +438,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/logme/instance/delete/delete.go b/internal/cmd/logme/instance/delete/delete.go index 315a2b3be..0dc766e3b 100644 --- a/internal/cmd/logme/instance/delete/delete.go +++ b/internal/cmd/logme/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a LogMe instance", @@ -41,29 +43,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := logmeUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -75,20 +75,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instacne", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for LogMe instance deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -108,15 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/logme/instance/delete/delete_test.go b/internal/cmd/logme/instance/delete/delete_test.go index 607bc9bd2..f2d599f6e 100644 --- a/internal/cmd/logme/instance/delete/delete_test.go +++ b/internal/cmd/logme/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,54 +137,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/logme/instance/describe/describe.go b/internal/cmd/logme/instance/describe/describe.go index d32944bdf..2c579779c 100644 --- a/internal/cmd/logme/instance/describe/describe.go +++ b/internal/cmd/logme/instance/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of a LogMe instance", @@ -46,12 +46,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read LogMe instance: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -82,15 +82,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -104,24 +96,7 @@ func outputResult(p *print.Printer, outputFormat string, instance *logme.Instanc return fmt.Errorf("instance is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal LogMe instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal LogMe instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(instance.InstanceId)) table.AddSeparator() @@ -149,5 +124,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *logme.Instanc } return nil - } + }) } diff --git a/internal/cmd/logme/instance/describe/describe_test.go b/internal/cmd/logme/instance/describe/describe_test.go index 3cc0b5e38..c20d5814f 100644 --- a/internal/cmd/logme/instance/describe/describe_test.go +++ b/internal/cmd/logme/instance/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,54 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -241,7 +197,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/logme/instance/instance.go b/internal/cmd/logme/instance/instance.go index 534151d57..184c1b27b 100644 --- a/internal/cmd/logme/instance/instance.go +++ b/internal/cmd/logme/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/logme/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/logme/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for LogMe instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/logme/instance/list/list.go b/internal/cmd/logme/instance/list/list.go index e59d09cb6..b3b15ce1c 100644 --- a/internal/cmd/logme/instance/list/list.go +++ b/internal/cmd/logme/instance/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logme" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/logme" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all LogMe instances", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 LogMe instances`, "$ stackit logme instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,15 +66,12 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get LogMe instances: %w", err) } - instances := *resp.Instances - if len(instances) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No instances found for project %q\n", projectLabel) - return nil + instances := resp.GetInstances() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } // Truncate output @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) }, } @@ -93,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *logme.APICl return req } -func outputResult(p *print.Printer, outputFormat string, instances []logme.Instance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal LogMe instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal LogMe instance list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []logme.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE") for i := range instances { @@ -172,5 +150,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []logme.Insta } return nil - } + }) } diff --git a/internal/cmd/logme/instance/list/list_test.go b/internal/cmd/logme/instance/list/list_test.go index 97df7394c..5104d046a 100644 --- a/internal/cmd/logme/instance/list/list_test.go +++ b/internal/cmd/logme/instance/list/list_test.go @@ -4,19 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/logme" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,8 +25,8 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -59,6 +59,7 @@ func fixtureRequest(mods ...func(request *logme.ApiListInstancesRequest)) logme. func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +78,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -113,48 +114,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -190,6 +150,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string instances []logme.Instance } tests := []struct { @@ -218,10 +179,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/logme/instance/update/update.go b/internal/cmd/logme/instance/update/update.go index 08972358e..9a16545ad 100644 --- a/internal/cmd/logme/instance/update/update.go +++ b/internal/cmd/logme/instance/update/update.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -55,7 +57,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates a LogMe instance", @@ -71,29 +73,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := logmeUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -113,20 +113,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for LogMe instance update: %w", err) } - s.Stop() } operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -195,15 +195,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/logme/instance/update/update_test.go b/internal/cmd/logme/instance/update/update_test.go index 4577de0ec..8dd59292c 100644 --- a/internal/cmd/logme/instance/update/update_test.go +++ b/internal/cmd/logme/instance/update/update_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -267,7 +269,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/logme/logme.go b/internal/cmd/logme/logme.go index 9dc8b77fd..a1371d7c1 100644 --- a/internal/cmd/logme/logme.go +++ b/internal/cmd/logme/logme.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/logme/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/logme/plans" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "logme", Short: "Provides functionality for LogMe", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(plans.NewCmd(p)) - cmd.AddCommand(credentials.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) } diff --git a/internal/cmd/logme/plans/plans.go b/internal/cmd/logme/plans/plans.go index ccfdc098b..76d762a1d 100644 --- a/internal/cmd/logme/plans/plans.go +++ b/internal/cmd/logme/plans/plans.go @@ -2,11 +2,13 @@ package plans import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logme" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/logme" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "plans", Short: "Lists all LogMe service plans", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 LogMe service plans`, "$ stackit logme plans --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,15 +66,12 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get LogMe service plans: %w", err) } - plans := *resp.Offerings - if len(plans) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No plans found for project %q\n", projectLabel) - return nil + plans := resp.GetOfferings() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } // Truncate output @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { plans = plans[:*model.Limit] } - return outputResult(p, model.OutputFormat, plans) + return outputResult(params.Printer, model.OutputFormat, projectLabel, plans) }, } @@ -93,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *logme.APICl return req } -func outputResult(p *print.Printer, outputFormat string, plans []logme.Offering) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(plans, "", " ") - if err != nil { - return fmt.Errorf("marshal LogMe plans: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, plans []logme.Offering) error { + return p.OutputResult(outputFormat, plans, func() error { + if len(plans) == 0 { + p.Outputf("No plans found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(plans, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal LogMe plans: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { @@ -173,5 +151,5 @@ func outputResult(p *print.Printer, outputFormat string, plans []logme.Offering) } return nil - } + }) } diff --git a/internal/cmd/logme/plans/plans_test.go b/internal/cmd/logme/plans/plans_test.go index bc8c78bb7..985b0a388 100644 --- a/internal/cmd/logme/plans/plans_test.go +++ b/internal/cmd/logme/plans/plans_test.go @@ -4,19 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/logme" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,8 +25,8 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -59,6 +59,7 @@ func fixtureRequest(mods ...func(request *logme.ApiListOfferingsRequest)) logme. func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +78,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -113,48 +114,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -190,6 +150,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string plans []logme.Offering } tests := []struct { @@ -218,10 +179,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.plans); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.plans); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/logs/access_token/access_token.go b/internal/cmd/logs/access_token/access_token.go new file mode 100644 index 000000000..1013dfe77 --- /dev/null +++ b/internal/cmd/logs/access_token/access_token.go @@ -0,0 +1,38 @@ +package access_token + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/access_token/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/access_token/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/access_token/delete_all" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/access_token/delete_all_expired" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/access_token/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/access_token/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/access_token/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "access-token", + Short: "Provides functionality for Logs access-tokens", + Long: "Provides functionality for Logs access-tokens.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(delete_all.NewCmd(params)) + cmd.AddCommand(delete_all_expired.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/logs/access_token/create/create.go b/internal/cmd/logs/access_token/create/create.go new file mode 100644 index 000000000..852810a85 --- /dev/null +++ b/internal/cmd/logs/access_token/create/create.go @@ -0,0 +1,158 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + logsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/utils" + + "github.com/spf13/cobra" +) + +const ( + displayNameFlag = "display-name" + instanceIdFlag = "instance-id" + lifetimeFlag = "lifetime" + descriptionFlag = "description" + permissionsFlag = "permissions" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Description *string + DisplayName string + Lifetime *int64 + Permissions []string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a Logs access token", + Long: "Creates a Logs access token.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a access token with the display name "access-token-1" for the instance "xxx" with read and write permissions`, + `$ stackit logs access-token create --display-name access-token-1 --instance-id xxx --permissions read,write`, + ), + examples.NewExample( + `Create a write only access token with a description`, + `$ stackit logs access-token create --display-name access-token-2 --instance-id xxx --permissions write --description "Access token for service"`, + ), + examples.NewExample( + `Create a read only access token which expires in 30 days`, + `$ stackit logs access-token create --display-name access-token-3 --instance-id xxx --permissions read --lifetime 30`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + instanceLabel, err := logsUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to create a access token for the Logs instance %q in the project %q?", instanceLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Logs access-token : %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, instanceLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the Logs instance") + cmd.Flags().String(displayNameFlag, "", "Display name for the access token") + cmd.Flags().String(descriptionFlag, "", "Description of the access token") + cmd.Flags().Int64(lifetimeFlag, 0, "Lifetime of the access token in days [1 - 180]") + cmd.Flags().StringSlice(permissionsFlag, []string{}, `Permissions of the access token ["read" "write"]`) + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag, displayNameFlag, permissionsFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: flags.FlagToStringValue(p, cmd, displayNameFlag), + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Lifetime: flags.FlagToInt64Pointer(p, cmd, lifetimeFlag), + Permissions: flags.FlagToStringSliceValue(p, cmd, permissionsFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiCreateAccessTokenRequest { + req := apiClient.CreateAccessToken(ctx, model.ProjectId, model.Region, model.InstanceId) + + return req.CreateAccessTokenPayload(logs.CreateAccessTokenPayload{ + Description: model.Description, + DisplayName: &model.DisplayName, + Lifetime: model.Lifetime, + Permissions: &model.Permissions, + }) +} + +func outputResult(p *print.Printer, outputFormat, instanceLabel string, accessToken *logs.AccessToken) error { + if accessToken == nil { + return fmt.Errorf("access token cannot be nil") + } + return p.OutputResult(outputFormat, accessToken, func() error { + p.Outputf("Created access token for Logs instance %q.\n\nID: %s\nToken: %s\n", instanceLabel, utils.PtrValue(accessToken.Id), utils.PtrValue(accessToken.AccessToken)) + return nil + }) +} diff --git a/internal/cmd/logs/access_token/create/create_test.go b/internal/cmd/logs/access_token/create/create_test.go new file mode 100644 index 000000000..e29fc864e --- /dev/null +++ b/internal/cmd/logs/access_token/create/create_test.go @@ -0,0 +1,282 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/logs" +) + +const ( + testRegion = "eu01" + + testDisplayName = "display-name" + testDescription = "description" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + displayNameFlag: testDisplayName, + descriptionFlag: testDescription, + permissionsFlag: "read,write", + lifetimeFlag: "0", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + Description: utils.Ptr(testDescription), + DisplayName: testDisplayName, + Lifetime: utils.Ptr(int64(0)), + Permissions: []string{ + "read", + "write", + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiCreateAccessTokenRequest)) logs.ApiCreateAccessTokenRequest { + request := testClient.CreateAccessToken(testCtx, testProjectId, testRegion, testInstanceId) + request = request.CreateAccessTokenPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *logs.CreateAccessTokenPayload)) logs.CreateAccessTokenPayload { + payload := logs.CreateAccessTokenPayload{ + DisplayName: utils.Ptr(testDisplayName), + Description: utils.Ptr(testDescription), + Lifetime: utils.Ptr(int64(0)), + Permissions: utils.Ptr([]string{ + "read", + "write", + }), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "only required flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, lifetimeFlag) + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Lifetime = nil + model.Description = nil + }), + }, + { + description: "one permission", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[permissionsFlag] = "read" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Permissions = []string{ + "read", + } + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "lifetime invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[lifetimeFlag] = "invalid-integer" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + var tests = []struct { + description string + model *inputModel + expectedRequest logs.ApiCreateAccessTokenRequest + }{ + { + 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(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + instanceLabel string + accessToken *logs.AccessToken + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + instanceLabel: "", + accessToken: utils.Ptr(logs.AccessToken{ + Id: utils.Ptr(uuid.NewString()), + Permissions: utils.Ptr([]string{ + "read", + "write", + }), + DisplayName: utils.Ptr("Token"), + AccessToken: utils.Ptr("Secret access token"), + Creator: utils.Ptr(uuid.NewString()), + Expires: utils.Ptr(false), + Status: utils.Ptr(logs.ACCESSTOKENSTATUS_ACTIVE), + }), + }, + wantErr: false, + }, + { + name: "empty access token", + args: args{ + instanceLabel: "", + accessToken: utils.Ptr(logs.AccessToken{}), + }, + wantErr: false, + }, + { + name: "empty", + args: args{}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.accessToken); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/logs/access_token/delete/delete.go b/internal/cmd/logs/access_token/delete/delete.go new file mode 100644 index 000000000..d55c8d3a2 --- /dev/null +++ b/internal/cmd/logs/access_token/delete/delete.go @@ -0,0 +1,117 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + logUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + accessTokenIdArg = "ACCESS_TOKEN_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + AccessTokenId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", accessTokenIdArg), + Short: "Deletes a Logs access token", + Long: "Deletes a Logs access token.", + Args: args.SingleArg(accessTokenIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete access token with ID "xxx" in instance "yyy"`, + "$ stackit logs access-token delete xxx --instance-id yyy", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get the display name for confirmation + accessTokenLabel, err := logUtils.GetAccessTokenName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get access token: %v", err) + } + if accessTokenLabel == "" { + accessTokenLabel = model.AccessTokenId + } + + prompt := fmt.Sprintf("Are you sure you want to delete access token %q?", accessTokenLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete access token: %w", err) + } + + params.Printer.Outputf("Deleted access token %q\n", accessTokenLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the Logs instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + accessTokenId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AccessTokenId: accessTokenId, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiDeleteAccessTokenRequest { + return apiClient.DeleteAccessToken(ctx, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) +} diff --git a/internal/cmd/logs/access_token/delete/delete_test.go b/internal/cmd/logs/access_token/delete/delete_test.go new file mode 100644 index 000000000..23bbb5464 --- /dev/null +++ b/internal/cmd/logs/access_token/delete/delete_test.go @@ -0,0 +1,207 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/logs" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testAccessTokenId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAccessTokenId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + AccessTokenId: testAccessTokenId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiDeleteAccessTokenRequest)) logs.ApiDeleteAccessTokenRequest { + request := testClient.DeleteAccessToken(testCtx, testProjectId, testRegion, testInstanceId, testAccessTokenId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "access token id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "access token id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiDeleteAccessTokenRequest + }{ + { + 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/logs/access_token/delete_all/delete_all.go b/internal/cmd/logs/access_token/delete_all/delete_all.go new file mode 100644 index 000000000..e12e89804 --- /dev/null +++ b/internal/cmd/logs/access_token/delete_all/delete_all.go @@ -0,0 +1,112 @@ +package delete_all + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + logUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete-all", + Short: "Deletes all Logs access token", + Long: "Deletes all Logs access token.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Delete all access tokens in instance "xxx"`, + "$ stackit logs access-token delete-all --instance-id xxx", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := logUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to delete all access tokens for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + items, err := req.Execute() + if err != nil { + return fmt.Errorf("delete all access token: %w", err) + } + if items == nil { + return fmt.Errorf("delete all access token: nil result") + } + + params.Printer.Outputf("Deleted %d access token(s)\n", len(utils.PtrValue(items.Tokens))) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the Logs instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiDeleteAllAccessTokensRequest { + return apiClient.DeleteAllAccessTokens(ctx, model.ProjectId, model.Region, model.InstanceId) +} diff --git a/internal/cmd/logs/access_token/delete_all/delete_all_test.go b/internal/cmd/logs/access_token/delete_all/delete_all_test.go new file mode 100644 index 000000000..7cc8e8a81 --- /dev/null +++ b/internal/cmd/logs/access_token/delete_all/delete_all_test.go @@ -0,0 +1,163 @@ +package delete_all + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/logs" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiDeleteAllAccessTokensRequest)) logs.ApiDeleteAllAccessTokensRequest { + request := testClient.DeleteAllAccessTokens(testCtx, testProjectId, testRegion, 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flag values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiDeleteAllAccessTokensRequest + }{ + { + 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/logs/access_token/delete_all_expired/delete_all_expired.go b/internal/cmd/logs/access_token/delete_all_expired/delete_all_expired.go new file mode 100644 index 000000000..fbad9528a --- /dev/null +++ b/internal/cmd/logs/access_token/delete_all_expired/delete_all_expired.go @@ -0,0 +1,112 @@ +package delete_all_expired + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + logUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete-all-expired", + Short: "Deletes all expired Logs access token", + Long: "Deletes all expired Logs access token.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Delete all expired access tokens in instance "xxx"`, + "$ stackit logs access-token delete-all-expired --instance-id xxx", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + instanceLabel, err := logUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + prompt := fmt.Sprintf("Are you sure you want to delete all expired access tokens in instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + items, err := req.Execute() + if err != nil { + return fmt.Errorf("delete all expired access token: %w", err) + } + if items == nil { + return fmt.Errorf("delete all expired access token: nil result") + } + + params.Printer.Outputf("Deleted %d expired access token(s)\n", len(utils.PtrValue(items.Tokens))) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the Logs instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiDeleteAllAccessTokensRequest { + return apiClient.DeleteAllExpiredAccessTokens(ctx, model.ProjectId, model.Region, model.InstanceId) +} diff --git a/internal/cmd/logs/access_token/delete_all_expired/delete_all_expired_test.go b/internal/cmd/logs/access_token/delete_all_expired/delete_all_expired_test.go new file mode 100644 index 000000000..f369afa91 --- /dev/null +++ b/internal/cmd/logs/access_token/delete_all_expired/delete_all_expired_test.go @@ -0,0 +1,163 @@ +package delete_all_expired + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/logs" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiDeleteAllExpiredAccessTokensRequest)) logs.ApiDeleteAllExpiredAccessTokensRequest { + request := testClient.DeleteAllExpiredAccessTokens(testCtx, testProjectId, testRegion, 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flag values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiDeleteAllExpiredAccessTokensRequest + }{ + { + 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/logs/access_token/describe/describe.go b/internal/cmd/logs/access_token/describe/describe.go new file mode 100644 index 000000000..d81a8b258 --- /dev/null +++ b/internal/cmd/logs/access_token/describe/describe.go @@ -0,0 +1,136 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + accessTokenIdArg = "ACCESS_TOKEN_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + AccessTokenId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", accessTokenIdArg), + Short: "Shows details of a Logs access token", + Long: "Shows details of a Logs access token.", + Args: args.SingleArg(accessTokenIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Show details of a Logs access token with ID "xxx"`, + "$ stackit logs access-token describe xxx", + ), + examples.NewExample( + `Show details of a Logs access token with ID "xxx" in JSON format`, + "$ stackit logs access-token describe xxx --output-format json", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read access token: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the Logs instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + accessTokenId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + AccessTokenId: accessTokenId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiGetAccessTokenRequest { + return apiClient.GetAccessToken(ctx, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) +} + +func outputResult(p *print.Printer, outputFormat string, token *logs.AccessToken) error { + if token == nil { + return fmt.Errorf("access token cannot be nil") + } + return p.OutputResult(outputFormat, token, func() error { + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(token.Id)) + table.AddSeparator() + table.AddRow("DISPLAY NAME", utils.PtrString(token.DisplayName)) + table.AddSeparator() + table.AddRow("DESCRIPTION", utils.PtrString(token.Description)) + table.AddSeparator() + table.AddRow("PERMISSIONS", utils.PtrString(token.Permissions)) + table.AddSeparator() + table.AddRow("CREATOR", utils.PtrString(token.Creator)) + table.AddSeparator() + table.AddRow("STATE", utils.PtrString(token.Status)) + table.AddSeparator() + table.AddRow("EXPIRES", utils.PtrString(token.Expires)) + table.AddSeparator() + table.AddRow("VALID UNTIL", utils.PtrString(token.ValidUntil)) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/logs/access_token/describe/describe_test.go b/internal/cmd/logs/access_token/describe/describe_test.go new file mode 100644 index 000000000..be083cd72 --- /dev/null +++ b/internal/cmd/logs/access_token/describe/describe_test.go @@ -0,0 +1,263 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/logs" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testAccessTokenId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAccessTokenId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + AccessTokenId: testAccessTokenId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiGetAccessTokenRequest)) logs.ApiGetAccessTokenRequest { + request := testClient.GetAccessToken(testCtx, testProjectId, testRegion, testInstanceId, testAccessTokenId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "access token id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "access token id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiGetAccessTokenRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + accessToken *logs.AccessToken + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + accessToken: utils.Ptr(logs.AccessToken{ + Id: utils.Ptr(uuid.NewString()), + Permissions: utils.Ptr([]string{ + "read", + "write", + }), + DisplayName: utils.Ptr("Token"), + AccessToken: utils.Ptr("Secret access token"), + Creator: utils.Ptr(uuid.NewString()), + Expires: utils.Ptr(false), + Status: utils.Ptr(logs.ACCESSTOKENSTATUS_ACTIVE), + }), + }, + wantErr: false, + }, + { + name: "set empty access token", + args: args{ + accessToken: utils.Ptr(logs.AccessToken{}), + }, + wantErr: false, + }, + { + name: "empty", + args: args{}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.accessToken); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/logs/access_token/list/list.go b/internal/cmd/logs/access_token/list/list.go new file mode 100644 index 000000000..27967bdc8 --- /dev/null +++ b/internal/cmd/logs/access_token/list/list.go @@ -0,0 +1,161 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + limitFlag = "limit" + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all Logs access tokens of a project", + Long: "Lists all access tokens of a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all access tokens of the instance "xxx"`, + "$ stackit logs access-token list --instance-id xxx", + ), + examples.NewExample( + `Lists all access tokens in JSON format`, + "$ stackit logs access-token list --instance-id xxx --output-format json", + ), + examples.NewExample( + `Lists up to 10 access-token`, + "$ stackit logs access-token list --instance-id xxx --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list access tokens: %w", err) + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + // Truncate output + items := utils.PtrValue(resp.Tokens) + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, items, projectLabel) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the Logs instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiListAccessTokensRequest { + return apiClient.ListAccessTokens(ctx, model.ProjectId, model.Region, model.InstanceId) +} + +func outputResult(p *print.Printer, outputFormat string, tokens []logs.AccessToken, projectLabel string) error { + return p.OutputResult(outputFormat, tokens, func() error { + if len(tokens) == 0 { + p.Outputf("No access token found for project %q\n", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "PERMISSIONS", "VALID UNTIL", "STATUS") + + for _, token := range tokens { + table.AddRow( + utils.PtrString(token.Id), + utils.PtrString(token.DisplayName), + utils.PtrString(token.Description), + utils.PtrString(token.Permissions), + utils.PtrString(token.ValidUntil), + utils.PtrString(token.Status), + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/logs/access_token/list/list_test.go b/internal/cmd/logs/access_token/list/list_test.go new file mode 100644 index 000000000..41c8c2b29 --- /dev/null +++ b/internal/cmd/logs/access_token/list/list_test.go @@ -0,0 +1,240 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/logs" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiListAccessTokensRequest)) logs.ApiListAccessTokensRequest { + request := testClient.ListAccessTokens(testCtx, testProjectId, testRegion, 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no flag values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "instance id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + accessTokens []logs.AccessToken + projectLabel string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "base", + args: args{ + accessTokens: []logs.AccessToken{ + { + Id: utils.Ptr(uuid.NewString()), + Permissions: utils.Ptr([]string{ + "read", + "write", + }), + DisplayName: utils.Ptr("Token"), + AccessToken: utils.Ptr("Secret access token"), + Creator: utils.Ptr(uuid.NewString()), + Expires: utils.Ptr(false), + Status: utils.Ptr(logs.ACCESSTOKENSTATUS_ACTIVE), + }, + }, + }, + wantErr: false, + }, + { + name: "set empty access token", + args: args{ + accessTokens: []logs.AccessToken{ + {}, + }, + }, + wantErr: false, + }, + { + name: "empty", + args: args{}, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.accessTokens, tt.args.projectLabel); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/logs/access_token/update/update.go b/internal/cmd/logs/access_token/update/update.go new file mode 100644 index 000000000..6fe999dba --- /dev/null +++ b/internal/cmd/logs/access_token/update/update.go @@ -0,0 +1,145 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + logUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +const ( + instanceIdFlag = "instance-id" + displayNameFlag = "display-name" + descriptionFlag = "description" + accessTokenIdArg = "ACCESS_TOKEN_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string + AccessTokenId string + Description *string + DisplayName *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", accessTokenIdArg), + Short: "Updates a Logs access token", + Long: "Updates a access token.", + Args: args.SingleArg(accessTokenIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update access token with ID "xxx" with new name "access-token-1"`, + `$ stackit logs access-token update xxx --instance-id yyy --display-name access-token-1`, + ), + examples.NewExample( + `Update access token with ID "xxx" with new description "Access token for Service XY"`, + `$ stackit logs access-token update xxx --instance-id yyy --description "Access token for Service XY"`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get the display name for confirmation + instanceLabel, err := logUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get Logs instance: %v", err) + } + if instanceLabel == "" { + instanceLabel = model.InstanceId + } + + // Get the display name for confirmation + accessTokenLabel, err := logUtils.GetAccessTokenName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get access token: %v", err) + } + if accessTokenLabel == "" { + accessTokenLabel = model.AccessTokenId + } + + prompt := fmt.Sprintf("Are you sure you want to update access token %q for instance %q?", accessTokenLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("update access token: %w", err) + } + + params.Printer.Outputf("Updated access token %q\n", accessTokenLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the Logs instance") + cmd.Flags().String(displayNameFlag, "", "Display name for the access token") + cmd.Flags().String(descriptionFlag, "", "Description of the access token") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + accessTokenId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + AccessTokenId: accessTokenId, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiUpdateAccessTokenRequest { + req := apiClient.UpdateAccessToken(ctx, model.ProjectId, model.Region, model.InstanceId, model.AccessTokenId) + + payload := logs.UpdateAccessTokenPayload{ + DisplayName: model.DisplayName, + Description: model.Description, + } + + return req.UpdateAccessTokenPayload(payload) +} diff --git a/internal/cmd/logs/access_token/update/update_test.go b/internal/cmd/logs/access_token/update/update_test.go new file mode 100644 index 000000000..fa458e5fc --- /dev/null +++ b/internal/cmd/logs/access_token/update/update_test.go @@ -0,0 +1,277 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/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/logs" +) + +const ( + testRegion = "eu01" + + testDisplayName = "display-name" + testDescription = "description" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testAccessTokenId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testAccessTokenId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + instanceIdFlag: testInstanceId, + displayNameFlag: testDisplayName, + descriptionFlag: testDescription, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + + InstanceId: testInstanceId, + AccessTokenId: testAccessTokenId, + DisplayName: utils.Ptr(testDisplayName), + Description: utils.Ptr(testDescription), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiUpdateAccessTokenRequest)) logs.ApiUpdateAccessTokenRequest { + request := testClient.UpdateAccessToken(testCtx, testProjectId, testRegion, testInstanceId, testAccessTokenId) + request = request.UpdateAccessTokenPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *logs.UpdateAccessTokenPayload)) logs.UpdateAccessTokenPayload { + payload := logs.UpdateAccessTokenPayload{ + DisplayName: utils.Ptr(testDisplayName), + Description: utils.Ptr(testDescription), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +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: "required only", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DisplayName = nil + model.Description = nil + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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: "access token id invalid 1", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "access token id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + 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.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + model, err := parseInput(p, 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 logs.ApiUpdateAccessTokenRequest + }{ + { + 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/logs/instance/create/create.go b/internal/cmd/logs/instance/create/create.go new file mode 100644 index 000000000..a7fca44c3 --- /dev/null +++ b/internal/cmd/logs/instance/create/create.go @@ -0,0 +1,179 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + + "github.com/stackitcloud/stackit-sdk-go/services/logs/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + displayNameFlag = "display-name" + retentionDaysFlag = "retention-days" + aclFlag = "acl" + descriptionFlag = "description" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + DisplayName *string + RetentionDays *int64 + ACL *[]string + Description *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a Logs instance", + Long: "Creates a Logs instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a Logs instance with name "my-instance" and retention time 10 days`, + `$ stackit logs instance create --display-name "my-instance" --retention-days 10`), + examples.NewExample( + `Create a Logs instance with name "my-instance", retention time 10 days, and a description`, + `$ stackit logs instance create --display-name "my-instance" --retention-days 10 --description "Description of the instance"`), + examples.NewExample( + `Create a Logs instance with name "my-instance", retention time 10 days, and restrict access to a specific range of IP addresses.`, + `$ stackit logs instance create --display-name "my-instance" --retention-days 10 --acl 1.2.3.0/24`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create a Logs instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create Logs instance: %w", err) + } + if resp == nil { + return fmt.Errorf("create Logs instance: empty response from API") + } + if resp.Id == nil { + return fmt.Errorf("create Logs instance: instance id missing in response") + } + instanceId := *resp.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateLogsInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for logs instance creation: %w", err) + } + } + + return outputResult(params.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringSlice(aclFlag, []string{}, "Access control list") + cmd.Flags().Int64(retentionDaysFlag, 0, "The days for how long the logs should be stored before being cleaned up") + + err := flags.MarkFlagsRequired(cmd, displayNameFlag, retentionDaysFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + DisplayName: flags.FlagToStringPointer(p, cmd, displayNameFlag), + RetentionDays: flags.FlagToInt64Pointer(p, cmd, retentionDaysFlag), + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + ACL: flags.FlagToStringSlicePointer(p, cmd, aclFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiCreateLogsInstanceRequest { + req := apiClient.CreateLogsInstance(ctx, model.ProjectId, model.Region) + + req = req.CreateLogsInstancePayload(logs.CreateLogsInstancePayload{ + DisplayName: model.DisplayName, + Description: model.Description, + RetentionDays: model.RetentionDays, + Acl: model.ACL, + }) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *logs.LogsInstance) error { + if resp == nil { + return fmt.Errorf("create logs instance response is empty") + } else if model == nil || model.GlobalFlagModel == nil { + return fmt.Errorf("input model is nil") + } + + return p.OutputResult(model.OutputFormat, resp, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) + return nil + }) +} diff --git a/internal/cmd/logs/instance/create/create_test.go b/internal/cmd/logs/instance/create/create_test.go new file mode 100644 index 000000000..f533b16ec --- /dev/null +++ b/internal/cmd/logs/instance/create/create_test.go @@ -0,0 +1,258 @@ +package create + +import ( + "context" + "strconv" + "testing" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testRegion = "eu01" + testDisplayName = "my-logs-instance" + testDescription = "my instance description" + testAcl = "198.51.100.14/24" + testRetentionDays = 32 +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + testProjectId = uuid.NewString() +) + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: testDisplayName, + retentionDaysFlag: strconv.Itoa(testRetentionDays), + descriptionFlag: testDescription, + aclFlag: testAcl, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + DisplayName: utils.Ptr(testDisplayName), + Description: utils.Ptr(testDescription), + RetentionDays: utils.Ptr(int64(testRetentionDays)), + ACL: utils.Ptr([]string{testAcl}), + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *logs.ApiCreateLogsInstanceRequest)) logs.ApiCreateLogsInstanceRequest { + request := testClient.CreateLogsInstance(testCtx, testProjectId, testRegion) + request = request.CreateLogsInstancePayload(logs.CreateLogsInstancePayload{ + DisplayName: utils.Ptr(testDisplayName), + Description: utils.Ptr(testDescription), + RetentionDays: utils.Ptr(int64(testRetentionDays)), + Acl: utils.Ptr([]string{testAcl}), + }) + + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "optional flags omitted", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + delete(flagValues, aclFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.ACL = nil + }), + }, + { + description: "no values provided", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "display name missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, displayNameFlag) + }), + isValid: false, + }, + { + description: "retention days missing (required)", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, retentionDaysFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiCreateLogsInstanceRequest + }{ + { + description: "base case", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no optional values", + model: fixtureInputModel(func(model *inputModel) { + model.Description = nil + model.ACL = nil + }), + expectedRequest: fixtureRequest().CreateLogsInstancePayload(logs.CreateLogsInstancePayload{ + DisplayName: utils.Ptr(testDisplayName), + RetentionDays: utils.Ptr(int64(testRetentionDays)), + Description: nil, + Acl: nil, + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + instance *logs.LogsInstance + wantErr bool + }{ + { + description: "nil response", + instance: nil, + wantErr: true, + }, + { + description: "model is nil", + instance: &logs.LogsInstance{}, + model: nil, + wantErr: true, + }, + { + description: "global flag nil", + instance: &logs.LogsInstance{}, + model: &inputModel{GlobalFlagModel: nil}, + wantErr: true, + }, + { + description: "default output", + instance: &logs.LogsInstance{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + wantErr: false, + }, + { + description: "json output", + instance: &logs.LogsInstance{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + wantErr: false, + }, + { + description: "yaml output", + instance: &logs.LogsInstance{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.YAMLOutputFormat}}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.model, "label", tt.instance) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/logs/instance/delete/delete.go b/internal/cmd/logs/instance/delete/delete.go new file mode 100644 index 000000000..d00f33fc8 --- /dev/null +++ b/internal/cmd/logs/instance/delete/delete.go @@ -0,0 +1,132 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/logs/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + logsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/utils" +) + +const ( + argInstanceID = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", argInstanceID), + Short: "Deletes the given Logs instance", + Long: "Deletes the given Logs instance.", + Args: args.SingleArg(argInstanceID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a Logs instance with ID "xxx"`, + `$ stackit logs instance delete "xxx"`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + instanceLabel, err := logsUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceID + } + + prompt := fmt.Sprintf("Are you sure you want to delete instance %q from project %q? (This cannot be undone)", instanceLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete Logs instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteLogsInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.InstanceID).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for Logs instance deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Outputf("%s instance %q\n", operationState, instanceLabel) + return nil + }, + } + + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceID: instanceId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiDeleteLogsInstanceRequest { + req := apiClient.DeleteLogsInstance(ctx, model.ProjectId, model.Region, model.InstanceID) + return req +} diff --git a/internal/cmd/logs/instance/delete/delete_test.go b/internal/cmd/logs/instance/delete/delete_test.go new file mode 100644 index 000000000..64501bf2e --- /dev/null +++ b/internal/cmd/logs/instance/delete/delete_test.go @@ -0,0 +1,175 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu02" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *logs.ApiDeleteLogsInstanceRequest)) logs.ApiDeleteLogsInstanceRequest { + request := testClient.DeleteLogsInstance(testCtx, testProjectId, testRegion, 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(), + expectedModel: fixtureInputModel(), + isValid: true, + }, + { + description: "no args (instanceID)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiDeleteLogsInstanceRequest + }{ + { + description: "base case", + 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/logs/instance/describe/describe.go b/internal/cmd/logs/instance/describe/describe.go new file mode 100644 index 000000000..b145e6e66 --- /dev/null +++ b/internal/cmd/logs/instance/describe/describe.go @@ -0,0 +1,120 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + argInstanceID = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceID string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", argInstanceID), + Short: "Shows details of a Logs instance", + Long: "Shows details of a Logs instance", + Args: args.SingleArg(argInstanceID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a Logs instance with ID "xxx"`, + `$ stackit logs instance describe xxx`, + ), + examples.NewExample( + `Get details of a Logs instance with ID "xxx" in JSON format`, + "$ stackit logs instance describe xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get instance: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + model := &inputModel{ + GlobalFlagModel: globalFlags, + InstanceID: inputArgs[0], + } + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiGetLogsInstanceRequest { + return apiClient.GetLogsInstance(ctx, model.ProjectId, model.Region, model.InstanceID) +} + +func outputResult(p *print.Printer, outputFormat string, instance *logs.LogsInstance) error { + if instance == nil { + return fmt.Errorf("instance response is empty") + } + return p.OutputResult(outputFormat, instance, func() error { + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(instance.Id)) + table.AddSeparator() + table.AddRow("DISPLAY NAME", utils.PtrString(instance.DisplayName)) + table.AddSeparator() + table.AddRow("RETENTION DAYS", utils.PtrString(instance.RetentionDays)) + table.AddSeparator() + table.AddRow("ACL IP RANGES", utils.PtrString(instance.Acl)) + table.AddSeparator() + table.AddRow("DATA SOURCE", utils.PtrString(instance.DatasourceUrl)) + table.AddSeparator() + table.AddRow("OTLP INGEST", utils.PtrString(instance.IngestOtlpUrl)) + table.AddSeparator() + table.AddRow("INGEST", utils.PtrString(instance.IngestUrl)) + table.AddSeparator() + table.AddRow("QUERY RANGE", utils.PtrString(instance.QueryRangeUrl)) + table.AddSeparator() + table.AddRow("QUERY", utils.PtrString(instance.QueryUrl)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/logs/instance/describe/describe_test.go b/internal/cmd/logs/instance/describe/describe_test.go new file mode 100644 index 000000000..ed6afc9d4 --- /dev/null +++ b/internal/cmd/logs/instance/describe/describe_test.go @@ -0,0 +1,192 @@ +package describe + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logs.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiGetLogsInstanceRequest)) logs.ApiGetLogsInstanceRequest { + request := testClient.GetLogsInstance(testCtx, testProjectId, testRegion, 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: []string{testInstanceId}, + 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: "project id missing", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(m map[string]string) { delete(m, globalflags.ProjectIdFlag) }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: []string{testInstanceId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid instance id", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiGetLogsInstanceRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + instance *logs.LogsInstance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "default output", + args: args{outputFormat: "default", instance: &logs.LogsInstance{}}, + wantErr: false, + }, + { + name: "json output", + args: args{outputFormat: print.JSONOutputFormat, instance: &logs.LogsInstance{}}, + wantErr: false, + }, + { + name: "yaml output", + args: args{outputFormat: print.YAMLOutputFormat, instance: &logs.LogsInstance{}}, + wantErr: false, + }, + { + name: "nil instance", + args: args{instance: nil}, + wantErr: true, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/logs/instance/instance.go b/internal/cmd/logs/instance/instance.go new file mode 100644 index 000000000..f69b7ddce --- /dev/null +++ b/internal/cmd/logs/instance/instance.go @@ -0,0 +1,34 @@ +package instance + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/instance/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/instance/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/instance/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/instance/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/instance/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for Logs instances", + Long: "Provides functionality for Logs instances.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) +} diff --git a/internal/cmd/logs/instance/list/list.go b/internal/cmd/logs/instance/list/list.go new file mode 100644 index 000000000..153a4c2a9 --- /dev/null +++ b/internal/cmd/logs/instance/list/list.go @@ -0,0 +1,148 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +const ( + limitFlag = "limit" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists Logs instances", + Long: "Lists Logs instances within the project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all Logs instances`, + `$ stackit logs instance list`, + ), + examples.NewExample( + `List the first 10 Logs instances`, + `$ stackit logs instance list --limit=10`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list Logs instances: %w", err) + } + items := response.GetInstances() + + // Truncate output + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, items) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiListLogsInstancesRequest { + request := apiClient.ListLogsInstances(ctx, model.ProjectId, model.Region) + + return request +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []logs.LogsInstance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No Logs instances found for project %q", projectLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("NAME", "ID", "STATUS") + for _, instance := range instances { + table.AddRow( + utils.PtrString(instance.DisplayName), + utils.PtrString(instance.Id), + utils.PtrString(instance.Status), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/logs/instance/list/list_test.go b/internal/cmd/logs/instance/list/list_test.go new file mode 100644 index 000000000..987d0e8d8 --- /dev/null +++ b/internal/cmd/logs/instance/list/list_test.go @@ -0,0 +1,195 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/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/logs" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &logs.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiListLogsInstancesRequest)) logs.ApiListLogsInstancesRequest { + request := testClient.ListLogsInstances(testCtx, testProjectId, testRegion) + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiListLogsInstancesRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + instances []logs.LogsInstance + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "empty instances slice", + args: args{ + instances: []logs.LogsInstance{}, + }, + wantErr: false, + }, + { + name: "empty instance in instances slice", + args: args{ + instances: []logs.LogsInstance{{}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/logs/instance/update/update.go b/internal/cmd/logs/instance/update/update.go new file mode 100644 index 000000000..8898a2969 --- /dev/null +++ b/internal/cmd/logs/instance/update/update.go @@ -0,0 +1,165 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/client" + logsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/logs" +) + +const ( + argInstanceID = "INSTANCE_ID" + + displayNameFlag = "display-name" + retentionDaysFlag = "retention-days" + aclFlag = "acl" + descriptionFlag = "description" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceID string + DisplayName *string + RetentionDays *int64 + ACL *[]string + Description *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", argInstanceID), + Short: "Updates a Logs instance", + Long: "Updates a Logs instance.", + Args: args.SingleArg(argInstanceID, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the display name of the Logs instance with ID "xxx"`, + "$ stackit logs instance update xxx --display-name new-name"), + examples.NewExample( + `Update the retention time of the Logs instance with ID "xxx"`, + "$ stackit logs instance update xxx --retention-days 40"), + examples.NewExample( + `Update the ACL of the Logs instance with ID "xxx"`, + "$ stackit logs instance update xxx --acl 1.2.3.0/24"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + instanceLabel, err := logsUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceID + } + + prompt := fmt.Sprintf("Are you sure you want to update instance %s?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update logs instance: %w", err) + } + + return outputResult(params.Printer, model, projectLabel, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(displayNameFlag, "", "Display name") + cmd.Flags().String(descriptionFlag, "", "Description") + cmd.Flags().StringSlice(aclFlag, []string{}, "Access control list") + cmd.Flags().Int64(retentionDaysFlag, 0, "The days for how long the logs should be stored before being cleaned up") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + displayName := flags.FlagToStringPointer(p, cmd, displayNameFlag) + retentionDays := flags.FlagToInt64Pointer(p, cmd, retentionDaysFlag) + acl := flags.FlagToStringSlicePointer(p, cmd, aclFlag) + description := flags.FlagToStringPointer(p, cmd, descriptionFlag) + + if displayName == nil && retentionDays == nil && acl == nil && description == nil { + return nil, &errors.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceID: instanceId, + DisplayName: displayName, + ACL: acl, + Description: description, + RetentionDays: retentionDays, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *logs.APIClient) logs.ApiUpdateLogsInstanceRequest { + req := apiClient.UpdateLogsInstance(ctx, model.ProjectId, model.Region, model.InstanceID) + req = req.UpdateLogsInstancePayload(logs.UpdateLogsInstancePayload{ + DisplayName: model.DisplayName, + Acl: model.ACL, + RetentionDays: model.RetentionDays, + Description: model.Description, + }) + return req +} + +func outputResult(p *print.Printer, model *inputModel, projectLabel string, instance *logs.LogsInstance) error { + if instance == nil { + return fmt.Errorf("instance is nil") + } else if model == nil || model.GlobalFlagModel == nil { + return fmt.Errorf("input model is nil") + } + return p.OutputResult(model.OutputFormat, instance, func() error { + p.Outputf("Updated instance %q for project %q.\n", utils.PtrString(instance.DisplayName), projectLabel) + return nil + }) +} diff --git a/internal/cmd/logs/instance/update/update_test.go b/internal/cmd/logs/instance/update/update_test.go new file mode 100644 index 000000000..07e43ea28 --- /dev/null +++ b/internal/cmd/logs/instance/update/update_test.go @@ -0,0 +1,307 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &logs.APIClient{} + testProjectId = uuid.NewString() + 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{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: "name", + aclFlag: "0.0.0.0/0", + retentionDaysFlag: "60", + descriptionFlag: "Example", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + DisplayName: utils.Ptr("name"), + ACL: utils.Ptr([]string{"0.0.0.0/0"}), + RetentionDays: utils.Ptr(int64(60)), + Description: utils.Ptr("Example"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *logs.ApiUpdateLogsInstanceRequest)) logs.ApiUpdateLogsInstanceRequest { + request := testClient.UpdateLogsInstance(testCtx, testProjectId, testRegion, testInstanceId) + request = request.UpdateLogsInstancePayload(logs.UpdateLogsInstancePayload{ + DisplayName: utils.Ptr("name"), + Acl: utils.Ptr([]string{"0.0.0.0/0"}), + RetentionDays: utils.Ptr(int64(60)), + Description: utils.Ptr("Example"), + }) + 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{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + }, + isValid: false, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + }, + }, + { + description: "update all fields", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + displayNameFlag: "display-name", + aclFlag: "0.0.0.0/24", + descriptionFlag: "description", + retentionDaysFlag: "60", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + DisplayName: utils.Ptr("display-name"), + ACL: utils.Ptr([]string{"0.0.0.0/24"}), + RetentionDays: utils.Ptr(int64(60)), + Description: utils.Ptr("description"), + }, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest logs.ApiUpdateLogsInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceID: testInstanceId, + }, + expectedRequest: testClient.UpdateLogsInstance(testCtx, testProjectId, testRegion, testInstanceId). + UpdateLogsInstancePayload(logs.UpdateLogsInstancePayload{}), + }, + } + + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + description string + model *inputModel + instance *logs.LogsInstance + wantErr bool + }{ + { + description: "nil response", + instance: nil, + wantErr: true, + }, + { + description: "default output", + instance: &logs.LogsInstance{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, + wantErr: false, + }, + { + description: "model is nil", + instance: &logs.LogsInstance{}, + model: nil, + wantErr: true, + }, + { + description: "global flag nil", + instance: &logs.LogsInstance{}, + model: &inputModel{GlobalFlagModel: nil}, + wantErr: true, + }, + { + description: "json output", + instance: &logs.LogsInstance{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.JSONOutputFormat}}, + wantErr: false, + }, + { + description: "yaml output", + instance: &logs.LogsInstance{}, + model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{OutputFormat: print.YAMLOutputFormat}}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + err := outputResult(p, tt.model, "label", tt.instance) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/logs/logs.go b/internal/cmd/logs/logs.go new file mode 100644 index 000000000..e72cc5830 --- /dev/null +++ b/internal/cmd/logs/logs.go @@ -0,0 +1,28 @@ +package logs + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/access_token" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs/instance" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "logs", + Short: "Provides functionality for Logs", + Long: "Provides functionality for Logs.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(access_token.NewCmd(params)) +} diff --git a/internal/cmd/mariadb/credentials/create/create.go b/internal/cmd/mariadb/credentials/create/create.go index 880444860..f3d9e1155 100644 --- a/internal/cmd/mariadb/credentials/create/create.go +++ b/internal/cmd/mariadb/credentials/create/create.go @@ -2,10 +2,10 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -31,7 +31,7 @@ type inputModel struct { ShowPassword bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates credentials for a MariaDB instance", @@ -45,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create credentials for a MariaDB instance and show the password in the output`, "$ stackit mariadb credentials create --instance-id xxx --show-password"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := mariadbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -79,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create MariaDB credentials: %w", err) } - return outputResult(p, model.OutputFormat, model.ShowPassword, instanceLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.ShowPassword, instanceLabel, resp) }, } configureFlags(cmd) @@ -94,7 +92,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -106,15 +104,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ShowPassword: flags.FlagToBoolValue(p, cmd, showPasswordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -131,24 +121,8 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, inst if !showPassword && resp.HasRaw() && resp.Raw.Credentials != nil { resp.Raw.Credentials.Password = utils.Ptr("hidden") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal MariaDB credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MariaDB credentials list: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, utils.PtrString(resp.Id)) // The username field cannot be set by the user, so we only display it if it's not returned empty if resp.HasRaw() && resp.Raw.Credentials != nil { @@ -165,5 +139,5 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, inst } p.Outputf("URI: %s\n", utils.PtrString(resp.Uri)) return nil - } + }) } diff --git a/internal/cmd/mariadb/credentials/create/create_test.go b/internal/cmd/mariadb/credentials/create/create_test.go index d3202e596..d89804299 100644 --- a/internal/cmd/mariadb/credentials/create/create_test.go +++ b/internal/cmd/mariadb/credentials/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -58,6 +61,7 @@ func fixtureRequest(mods ...func(request *mariadb.ApiCreateCredentialsRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -129,46 +133,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -228,7 +193,7 @@ func TestOutputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.showPassword, tt.args.instanceLabel, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mariadb/credentials/credentials.go b/internal/cmd/mariadb/credentials/credentials.go index f8fb1c5d2..7f216ad4b 100644 --- a/internal/cmd/mariadb/credentials/credentials.go +++ b/internal/cmd/mariadb/credentials/credentials.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/credentials/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/credentials/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", Short: "Provides functionality for MariaDB credentials", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/mariadb/credentials/delete/delete.go b/internal/cmd/mariadb/credentials/delete/delete.go index 4e8185624..7fd89c4e9 100644 --- a/internal/cmd/mariadb/credentials/delete/delete.go +++ b/internal/cmd/mariadb/credentials/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialsIdArg), Short: "Deletes credentials of a MariaDB instance", @@ -43,35 +45,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := mariadbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } credentialsLabel, err := mariadbUtils.GetCredentialsUsername(ctx, apiClient, model.ProjectId, model.InstanceId, model.CredentialsId) if err != nil { - p.Debug(print.ErrorLevel, "get credentials username: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials username: %v", err) credentialsLabel = model.CredentialsId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,7 +81,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete MariaDB credentials: %w", err) } - p.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) + params.Printer.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) return nil }, } @@ -110,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/mariadb/credentials/delete/delete_test.go b/internal/cmd/mariadb/credentials/delete/delete_test.go index c1b2560e5..5f8ba6638 100644 --- a/internal/cmd/mariadb/credentials/delete/delete_test.go +++ b/internal/cmd/mariadb/credentials/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -164,54 +164,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/mariadb/credentials/describe/describe.go b/internal/cmd/mariadb/credentials/describe/describe.go index 8e3e3f869..368e13e88 100644 --- a/internal/cmd/mariadb/credentials/describe/describe.go +++ b/internal/cmd/mariadb/credentials/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", credentialsIdArg), Short: "Shows details of credentials of a MariaDB instance", @@ -48,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe MariaDB credentials: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -94,15 +94,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -116,24 +108,7 @@ func outputResult(p *print.Printer, outputFormat string, credentials *mariadb.Cr return fmt.Errorf("credentials is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal MariaDB credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MariaDB credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, credentials, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(credentials.Id)) table.AddSeparator() @@ -153,5 +128,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials *mariadb.Cr } return nil - } + }) } diff --git a/internal/cmd/mariadb/credentials/describe/describe_test.go b/internal/cmd/mariadb/credentials/describe/describe_test.go index f822d88f4..554add42c 100644 --- a/internal/cmd/mariadb/credentials/describe/describe_test.go +++ b/internal/cmd/mariadb/credentials/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -164,54 +167,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -269,7 +225,7 @@ func TestOutputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mariadb/credentials/list/list.go b/internal/cmd/mariadb/credentials/list/list.go index b854d6c66..339891e99 100644 --- a/internal/cmd/mariadb/credentials/list/list.go +++ b/internal/cmd/mariadb/credentials/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( mariadbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mariadb" ) const ( @@ -31,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all credentials' IDs for a MariaDB instance", @@ -48,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 credentials' IDs for a MariaDB instance`, "$ stackit mariadb credentials list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,22 +68,19 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list MariaDB credentials: %w", err) } - credentials := *resp.CredentialsList - if len(credentials) == 0 { - instanceLabel, err := mariadbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) - if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) - instanceLabel = model.InstanceId - } - p.Info("No credentials found for instance %q\n", instanceLabel) - return nil + credentials := resp.GetCredentialsList() + + instanceLabel, err := mariadbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId } // Truncate output if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentials) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, credentials) }, } configureFlags(cmd) @@ -97,7 +95,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -117,15 +115,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -134,25 +124,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *mariadb.API return req } -func outputResult(p *print.Printer, outputFormat string, credentials []mariadb.CredentialsListItem) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal MariaDB credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MariaDB credentials list: %w", err) +func outputResult(p *print.Printer, outputFormat, instanceLabel string, credentials []mariadb.CredentialsListItem) error { + return p.OutputResult(outputFormat, credentials, func() error { + if len(credentials) == 0 { + p.Outputf("No credentials found for instance %q\n", instanceLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID") for i := range credentials { @@ -165,5 +143,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials []mariadb.C } return nil - } + }) } diff --git a/internal/cmd/mariadb/credentials/list/list_test.go b/internal/cmd/mariadb/credentials/list/list_test.go index 978210a9f..fbc904da7 100644 --- a/internal/cmd/mariadb/credentials/list/list_test.go +++ b/internal/cmd/mariadb/credentials/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mariadb" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,9 +26,9 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -61,6 +62,7 @@ func fixtureRequest(mods ...func(request *mariadb.ApiListCredentialsRequest)) ma func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -79,21 +81,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,46 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -210,8 +173,9 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { - outputFormat string - credentials []mariadb.CredentialsListItem + outputFormat string + instanceLabel string + credentials []mariadb.CredentialsListItem } tests := []struct { name string @@ -240,10 +204,10 @@ func TestOutputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.credentials); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/mariadb/instance/create/create.go b/internal/cmd/mariadb/instance/create/create.go index 3a2a4e4fb..de4c22b70 100644 --- a/internal/cmd/mariadb/instance/create/create.go +++ b/internal/cmd/mariadb/instance/create/create.go @@ -2,12 +2,12 @@ package create import ( "context" - "encoding/json" "errors" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -55,7 +55,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a MariaDB instance", @@ -72,31 +72,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a MariaDB instance with name "my-instance" and specify IP range which is allowed to access it`, "$ stackit mariadb instance create --name my-instance --plan-id xxx --acl 1.2.3.0/24"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a MariaDB instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a MariaDB instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -116,16 +114,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for MariaDB instance creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) }, } configureFlags(cmd) @@ -149,7 +147,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -185,15 +183,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -256,29 +246,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, projectLabe return fmt.Errorf("response is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal MariaDB instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MariaDB instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, utils.PtrString(resp.InstanceId)) return nil - } + }) } diff --git a/internal/cmd/mariadb/instance/create/create_test.go b/internal/cmd/mariadb/instance/create/create_test.go index 0e797ff1e..8001a4b2a 100644 --- a/internal/cmd/mariadb/instance/create/create_test.go +++ b/internal/cmd/mariadb/instance/create/create_test.go @@ -5,8 +5,11 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -107,6 +110,7 @@ func fixtureRequest(mods ...func(request *mariadb.ApiCreateInstanceRequest)) mar func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string sgwAclValues []string syslogValues []string @@ -261,66 +265,10 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.sgwAclValues { - err := cmd.Flags().Set(sgwAclFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) - } - } - - for _, value := range tt.syslogValues { - err := cmd.Flags().Set(syslogFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) - } - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + sgwAclFlag: tt.sgwAclValues, + syslogFlag: tt.syslogValues, + }, tt.isValid) }) } } @@ -491,7 +439,7 @@ func TestOutputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mariadb/instance/delete/delete.go b/internal/cmd/mariadb/instance/delete/delete.go index e28fb3593..db499df05 100644 --- a/internal/cmd/mariadb/instance/delete/delete.go +++ b/internal/cmd/mariadb/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a MariaDB instance", @@ -41,29 +43,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := mariadbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -75,20 +75,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for MariaDB instance deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -108,15 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/mariadb/instance/delete/delete_test.go b/internal/cmd/mariadb/instance/delete/delete_test.go index 4dbac6693..c3930a9c8 100644 --- a/internal/cmd/mariadb/instance/delete/delete_test.go +++ b/internal/cmd/mariadb/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,54 +137,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/mariadb/instance/describe/describe.go b/internal/cmd/mariadb/instance/describe/describe.go index b3b8013a6..0757fbe74 100644 --- a/internal/cmd/mariadb/instance/describe/describe.go +++ b/internal/cmd/mariadb/instance/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of a MariaDB instance", @@ -46,12 +46,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read MariaDB instance: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -82,15 +82,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -104,24 +96,7 @@ func outputResult(p *print.Printer, outputFormat string, instance *mariadb.Insta return fmt.Errorf("instance is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal MariaDB instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MariaDB instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(instance.InstanceId)) table.AddSeparator() @@ -151,5 +126,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *mariadb.Insta } return nil - } + }) } diff --git a/internal/cmd/mariadb/instance/describe/describe_test.go b/internal/cmd/mariadb/instance/describe/describe_test.go index 555f7bcbf..d8b5bda20 100644 --- a/internal/cmd/mariadb/instance/describe/describe_test.go +++ b/internal/cmd/mariadb/instance/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,54 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -242,7 +198,7 @@ func TestOutputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mariadb/instance/instance.go b/internal/cmd/mariadb/instance/instance.go index 71e25309a..e46e875f8 100644 --- a/internal/cmd/mariadb/instance/instance.go +++ b/internal/cmd/mariadb/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for MariaDB instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/mariadb/instance/list/list.go b/internal/cmd/mariadb/instance/list/list.go index 2b2dbe70c..a009ed2ee 100644 --- a/internal/cmd/mariadb/instance/list/list.go +++ b/internal/cmd/mariadb/instance/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mariadb" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all MariaDB instances", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 MariaDB instances`, "$ stackit mariadb instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,15 +66,12 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get MariaDB instances: %w", err) } - instances := *resp.Instances - if len(instances) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No instances found for project %q\n", projectLabel) - return nil + instances := resp.GetInstances() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } // Truncate output @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) }, } @@ -93,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *mariadb.API return req } -func outputResult(p *print.Printer, outputFormat string, instances []mariadb.Instance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal MariaDB instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MariaDB instance list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []mariadb.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE") for i := range instances { @@ -172,5 +150,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []mariadb.Ins } return nil - } + }) } diff --git a/internal/cmd/mariadb/instance/list/list_test.go b/internal/cmd/mariadb/instance/list/list_test.go index a7b29714f..ff8f033cf 100644 --- a/internal/cmd/mariadb/instance/list/list_test.go +++ b/internal/cmd/mariadb/instance/list/list_test.go @@ -4,19 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/mariadb" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,8 +25,8 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -59,6 +59,7 @@ func fixtureRequest(mods ...func(request *mariadb.ApiListInstancesRequest)) mari func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +78,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -113,48 +114,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -190,6 +150,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string instances []mariadb.Instance } tests := []struct { @@ -219,10 +180,10 @@ func TestOutputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/mariadb/instance/update/update.go b/internal/cmd/mariadb/instance/update/update.go index a2b70759e..94a7a871d 100644 --- a/internal/cmd/mariadb/instance/update/update.go +++ b/internal/cmd/mariadb/instance/update/update.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -53,7 +55,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates a MariaDB instance", @@ -69,29 +71,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := mariadbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -111,20 +111,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for MariaDB instance update: %w", err) } - s.Stop() } operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -193,15 +193,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/mariadb/instance/update/update_test.go b/internal/cmd/mariadb/instance/update/update_test.go index 514820867..d2fba4758 100644 --- a/internal/cmd/mariadb/instance/update/update_test.go +++ b/internal/cmd/mariadb/instance/update/update_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -278,7 +280,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/mariadb/mariadb.go b/internal/cmd/mariadb/mariadb.go index 7058813f9..5f8c41185 100644 --- a/internal/cmd/mariadb/mariadb.go +++ b/internal/cmd/mariadb/mariadb.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb/plans" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "mariadb", Short: "Provides functionality for MariaDB", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(plans.NewCmd(p)) - cmd.AddCommand(credentials.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) } diff --git a/internal/cmd/mariadb/plans/plans.go b/internal/cmd/mariadb/plans/plans.go index e4d3bf021..5c5819695 100644 --- a/internal/cmd/mariadb/plans/plans.go +++ b/internal/cmd/mariadb/plans/plans.go @@ -2,11 +2,13 @@ package plans import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mariadb" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mariadb" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "plans", Short: "Lists all MariaDB service plans", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 MariaDB service plans`, "$ stackit mariadb plans --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,15 +66,12 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get MariaDB service plans: %w", err) } - plans := *resp.Offerings - if len(plans) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No plans found for project %q\n", projectLabel) - return nil + plans := resp.GetOfferings() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } // Truncate output @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { plans = plans[:*model.Limit] } - return outputResult(p, model.OutputFormat, plans) + return outputResult(params.Printer, model.OutputFormat, projectLabel, plans) }, } @@ -93,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *mariadb.API return req } -func outputResult(p *print.Printer, outputFormat string, plans []mariadb.Offering) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(plans, "", " ") - if err != nil { - return fmt.Errorf("marshal MariaDB plans: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, plans []mariadb.Offering) error { + return p.OutputResult(outputFormat, plans, func() error { + if len(plans) == 0 { + p.Outputf("No plans found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(plans, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MariaDB plans: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { @@ -173,5 +151,5 @@ func outputResult(p *print.Printer, outputFormat string, plans []mariadb.Offerin } return nil - } + }) } diff --git a/internal/cmd/mariadb/plans/plans_test.go b/internal/cmd/mariadb/plans/plans_test.go index 3d8f56a28..3c8cf58fb 100644 --- a/internal/cmd/mariadb/plans/plans_test.go +++ b/internal/cmd/mariadb/plans/plans_test.go @@ -4,19 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/mariadb" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,8 +25,8 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -59,6 +59,7 @@ func fixtureRequest(mods ...func(request *mariadb.ApiListOfferingsRequest)) mari func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +78,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -113,48 +114,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -190,6 +150,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string plans []mariadb.Offering } tests := []struct { @@ -219,10 +180,10 @@ func TestOutputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.plans); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.plans); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/mongodbflex/backup/backup.go b/internal/cmd/mongodbflex/backup/backup.go index 738363d78..e9b3e79d1 100644 --- a/internal/cmd/mongodbflex/backup/backup.go +++ b/internal/cmd/mongodbflex/backup/backup.go @@ -8,13 +8,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/backup/schedule" updateschedule "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/backup/update-schedule" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "backup", Short: "Provides functionality for MongoDB Flex instance backups", @@ -22,15 +22,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(updateschedule.NewCmd(p)) - cmd.AddCommand(schedule.NewCmd(p)) - cmd.AddCommand(restore.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(restorejobs.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(updateschedule.NewCmd(params)) + cmd.AddCommand(schedule.NewCmd(params)) + cmd.AddCommand(restore.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(restorejobs.NewCmd(params)) } diff --git a/internal/cmd/mongodbflex/backup/describe/describe.go b/internal/cmd/mongodbflex/backup/describe/describe.go index a30fb3f63..1e34a88ee 100644 --- a/internal/cmd/mongodbflex/backup/describe/describe.go +++ b/internal/cmd/mongodbflex/backup/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( mongoUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) const ( @@ -33,7 +34,7 @@ type inputModel struct { BackupId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", backupIdArg), Short: "Shows details of a backup for a MongoDB Flex instance", @@ -49,20 +50,20 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(backupIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongoUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + instanceLabel, err := mongoUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } @@ -74,13 +75,13 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe backup for MongoDB Flex instance: %w", err) } - restoreJobs, err := apiClient.ListRestoreJobs(ctx, model.ProjectId, model.InstanceId).Execute() + restoreJobs, err := apiClient.ListRestoreJobs(ctx, model.ProjectId, model.InstanceId, model.Region).Execute() if err != nil { return fmt.Errorf("get restore jobs for MongoDB Flex instance %q: %w", instanceLabel, err) } restoreJobState := mongoUtils.GetRestoreStatus(model.BackupId, restoreJobs) - return outputResult(p, model.OutputFormat, restoreJobState, *resp.Item) + return outputResult(params.Printer, model.OutputFormat, restoreJobState, *resp.Item) }, } configureFlags(cmd) @@ -108,42 +109,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu BackupId: backupId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiGetBackupRequest { - req := apiClient.GetBackup(ctx, model.ProjectId, model.InstanceId, model.BackupId) + req := apiClient.GetBackup(ctx, model.ProjectId, model.InstanceId, model.BackupId, model.Region) return req } func outputResult(p *print.Printer, outputFormat, restoreStatus string, backup mongodbflex.Backup) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(backup, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex backup: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(backup, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex backup: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, backup, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(backup.Id)) table.AddSeparator() @@ -162,5 +138,5 @@ func outputResult(p *print.Printer, outputFormat, restoreStatus string, backup m } return nil - } + }) } diff --git a/internal/cmd/mongodbflex/backup/describe/describe_test.go b/internal/cmd/mongodbflex/backup/describe/describe_test.go index 0b761904d..d621f858b 100644 --- a/internal/cmd/mongodbflex/backup/describe/describe_test.go +++ b/internal/cmd/mongodbflex/backup/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" + testBackupId = "backupID" +) type testCtxKey struct{} @@ -21,7 +27,6 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &mongodbflex.APIClient{} var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() -var testBackupId = "backupID" func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -35,8 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -49,6 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, InstanceId: testInstanceId, BackupId: testBackupId, @@ -60,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiGetBackupRequest)) mongodbflex.ApiGetBackupRequest { - request := testClient.GetBackup(testCtx, testProjectId, testInstanceId, testBackupId) + request := testClient.GetBackup(testCtx, testProjectId, testInstanceId, testBackupId, testRegion) for _, mod := range mods { mod(&request) } @@ -104,7 +111,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +119,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +127,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -158,54 +165,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -263,7 +223,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.restoreStatus, tt.args.backup); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/backup/list/list.go b/internal/cmd/mongodbflex/backup/list/list.go index b145685fb..b75955b7a 100644 --- a/internal/cmd/mongodbflex/backup/list/list.go +++ b/internal/cmd/mongodbflex/backup/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +33,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all backups which are available for a MongoDB Flex instance", @@ -50,22 +50,22 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit mongodbflex backup list --instance-id xxx --limit 10"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId) + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = *model.InstanceId } @@ -75,13 +75,9 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get backups for MongoDB Flex instance %q: %w", instanceLabel, err) } - if resp.Items == nil || len(*resp.Items) == 0 { - cmd.Printf("No backups found for instance %q\n", instanceLabel) - return nil - } - backups := *resp.Items + backups := utils.GetSliceFromPointer(resp.Items) - restoreJobs, err := apiClient.ListRestoreJobs(ctx, model.ProjectId, *model.InstanceId).Execute() + restoreJobs, err := apiClient.ListRestoreJobs(ctx, model.ProjectId, *model.InstanceId, model.Region).Execute() if err != nil { return fmt.Errorf("get restore jobs for MongoDB Flex instance %q: %w", instanceLabel, err) } @@ -91,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { backups = backups[:*model.Limit] } - return outputResult(p, model.OutputFormat, backups, restoreJobs) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, backups, restoreJobs) }, } @@ -107,7 +103,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -127,46 +123,26 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiListBackupsRequest { - req := apiClient.ListBackups(ctx, model.ProjectId, *model.InstanceId) + req := apiClient.ListBackups(ctx, model.ProjectId, *model.InstanceId, model.Region) return req } -func outputResult(p *print.Printer, outputFormat string, backups []mongodbflex.Backup, restoreJobs *mongodbflex.ListRestoreJobsResponse) error { +func outputResult(p *print.Printer, outputFormat, instanceLabel string, backups []mongodbflex.Backup, restoreJobs *mongodbflex.ListRestoreJobsResponse) error { if restoreJobs == nil { return fmt.Errorf("restore jobs is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(backups, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex backups list: %w", err) + return p.OutputResult(outputFormat, backups, func() error { + if len(backups) == 0 { + p.Outputf("No backups found for instance %q\n", instanceLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(backups, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex backups list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID", "CREATED AT", "EXPIRES AT", "BACKUP SIZE", "RESTORE STATUS") for i := range backups { @@ -186,5 +162,5 @@ func outputResult(p *print.Printer, outputFormat string, backups []mongodbflex.B } return nil - } + }) } diff --git a/internal/cmd/mongodbflex/backup/list/list_test.go b/internal/cmd/mongodbflex/backup/list/list_test.go index 362b35c78..253b82936 100644 --- a/internal/cmd/mongodbflex/backup/list/list_test.go +++ b/internal/cmd/mongodbflex/backup/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -25,9 +30,10 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -40,6 +46,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, InstanceId: utils.Ptr(testInstanceId), Limit: utils.Ptr(int64(10)), @@ -51,7 +58,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiListBackupsRequest)) mongodbflex.ApiListBackupsRequest { - request := testClient.ListBackups(testCtx, testProjectId, testInstanceId) + request := testClient.ListBackups(testCtx, testProjectId, testInstanceId, testRegion) for _, mod := range mods { mod(&request) } @@ -61,6 +68,7 @@ func fixtureRequest(mods ...func(request *mongodbflex.ApiListBackupsRequest)) mo func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -79,21 +87,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,46 +144,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -210,9 +179,10 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { - outputFormat string - backups []mongodbflex.Backup - restoreJobs *mongodbflex.ListRestoreJobsResponse + outputFormat string + instanceLabel string + backups []mongodbflex.Backup + restoreJobs *mongodbflex.ListRestoreJobsResponse } tests := []struct { name string @@ -248,10 +218,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.backups, tt.args.restoreJobs); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.backups, tt.args.restoreJobs); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs.go b/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs.go index 30a48be34..ad3bf61de 100644 --- a/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs.go +++ b/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs.go @@ -2,11 +2,13 @@ package restorejobs import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( mongodbflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "restore-jobs", Short: "Lists all restore jobs which have been run for a MongoDB Flex instance", @@ -49,22 +50,22 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit mongodbflex backup restore-jobs --instance-id xxx --limit 10"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId) + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = *model.InstanceId } @@ -85,7 +86,7 @@ func NewCmd(p *print.Printer) *cobra.Command { restoreJobs = restoreJobs[:*model.Limit] } - return outputResult(p, model.OutputFormat, restoreJobs) + return outputResult(params.Printer, model.OutputFormat, restoreJobs) }, } @@ -101,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -121,42 +122,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiListRestoreJobsRequest { - req := apiClient.ListRestoreJobs(ctx, model.ProjectId, *model.InstanceId) + req := apiClient.ListRestoreJobs(ctx, model.ProjectId, *model.InstanceId, model.Region) return req } func outputResult(p *print.Printer, outputFormat string, restoreJobs []mongodbflex.RestoreInstanceStatus) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(restoreJobs, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex restore jobs list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(restoreJobs, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex restore jobs list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, restoreJobs, func() error { table := tables.NewTable() table.SetHeader("ID", "BACKUP ID", "BACKUP INSTANCE ID", "DATE", "STATUS") for i := range restoreJobs { @@ -176,5 +152,5 @@ func outputResult(p *print.Printer, outputFormat string, restoreJobs []mongodbfl } return nil - } + }) } diff --git a/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs_test.go b/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs_test.go index 5135be262..816148d5a 100644 --- a/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs_test.go +++ b/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -25,9 +30,10 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -39,6 +45,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: utils.Ptr(testInstanceId), @@ -51,7 +58,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiListRestoreJobsRequest)) mongodbflex.ApiListRestoreJobsRequest { - request := testClient.ListRestoreJobs(testCtx, testProjectId, testInstanceId) + request := testClient.ListRestoreJobs(testCtx, testProjectId, testInstanceId, testRegion) for _, mod := range mods { mod(&request) } @@ -61,6 +68,7 @@ func fixtureRequest(mods ...func(request *mongodbflex.ApiListRestoreJobsRequest) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -79,21 +87,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,46 +144,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -239,7 +208,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.restoreJobs); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/backup/restore/restore.go b/internal/cmd/mongodbflex/backup/restore/restore.go index c8de137fc..25171f7cb 100644 --- a/internal/cmd/mongodbflex/backup/restore/restore.go +++ b/internal/cmd/mongodbflex/backup/restore/restore.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -34,7 +36,7 @@ type inputModel struct { Timestamp string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "restore", Short: "Restores a MongoDB Flex instance from a backup", @@ -55,32 +57,30 @@ func NewCmd(p *print.Printer) *cobra.Command { `Restore a MongoDB Flex instance with ID "yyy", using backup from instance with ID "zzz" with backup ID "xxx"`, `$ stackit mongodbflex backup restore --instance-id zzz --backup-instance-id yyy --backup-id xxx`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + instanceLabel, err := mongodbUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to restore MongoDB Flex instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to restore MongoDB Flex instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // If backupInstanceId is not provided, the target is the same instance as the backup @@ -99,16 +99,20 @@ func NewCmd(p *print.Printer) *cobra.Command { } if !model.Async { - s := spinner.New(p) - s.Start("Restoring instance") - _, err = wait.RestoreInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.BackupId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Restoring instance", func() error { + _, err = wait.RestoreInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.BackupId, model.Region).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for MongoDB Flex instance restoration: %w", err) } - s.Stop() } - p.Outputf("Restored instance %q with backup %q\n", model.InstanceId, model.BackupId) + operationState := "Restored" + if model.Async { + operationState = "Triggered restore of" + } + params.Printer.Outputf("%s instance %q with backup %q\n", operationState, model.InstanceId, model.BackupId) return nil } @@ -120,16 +124,20 @@ func NewCmd(p *print.Printer) *cobra.Command { } if !model.Async { - s := spinner.New(p) - s.Start("Cloning instance") - _, err = wait.CloneInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Cloning instance", func() error { + _, err = wait.CloneInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for MongoDB Flex instance cloning: %w", err) } - s.Stop() } - p.Outputf("Cloned instance %q from backup with timestamp %q\n", model.InstanceId, model.Timestamp) + operationState := "Cloned" + if model.Async { + operationState = "Triggered clone of" + } + params.Printer.Outputf("%s instance %q from backup with timestamp %q\n", operationState, model.InstanceId, model.Timestamp) return nil }, } @@ -147,7 +155,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -170,20 +178,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Timestamp: flags.FlagToStringValue(p, cmd, timestampFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRestoreRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiRestoreInstanceRequest { - req := apiClient.RestoreInstance(ctx, model.ProjectId, model.InstanceId) + req := apiClient.RestoreInstance(ctx, model.ProjectId, model.InstanceId, model.Region) req = req.RestoreInstancePayload(mongodbflex.RestoreInstancePayload{ BackupId: &model.BackupId, InstanceId: &model.BackupInstanceId, @@ -192,7 +192,7 @@ func buildRestoreRequest(ctx context.Context, model *inputModel, apiClient *mong } func buildCloneRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiCloneInstanceRequest { - req := apiClient.CloneInstance(ctx, model.ProjectId, model.InstanceId) + req := apiClient.CloneInstance(ctx, model.ProjectId, model.InstanceId, model.Region) req = req.CloneInstancePayload(mongodbflex.CloneInstancePayload{ Timestamp: &model.Timestamp, InstanceId: &model.BackupInstanceId, diff --git a/internal/cmd/mongodbflex/backup/restore/restore_test.go b/internal/cmd/mongodbflex/backup/restore/restore_test.go index 63e06d9af..1fed3553f 100644 --- a/internal/cmd/mongodbflex/backup/restore/restore_test.go +++ b/internal/cmd/mongodbflex/backup/restore/restore_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,11 +14,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} const ( + testRegion = "eu02" testBackupId = "backupID" testTimestamp = "2021-01-01T00:00:00Z" ) @@ -32,10 +31,11 @@ var testBackupInstanceId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - backupIdFlag: testBackupId, - backupInstanceIdFlag: testBackupInstanceId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + backupIdFlag: testBackupId, + backupInstanceIdFlag: testBackupInstanceId, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -47,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -60,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRestoreRequest(mods ...func(request mongodbflex.ApiRestoreInstanceRequest)) mongodbflex.ApiRestoreInstanceRequest { - request := testClient.RestoreInstance(testCtx, testProjectId, testInstanceId) + request := testClient.RestoreInstance(testCtx, testProjectId, testInstanceId, testRegion) request = request.RestoreInstancePayload(mongodbflex.RestoreInstancePayload{ BackupId: utils.Ptr(testBackupId), InstanceId: utils.Ptr(testBackupInstanceId), @@ -72,7 +73,7 @@ func fixtureRestoreRequest(mods ...func(request mongodbflex.ApiRestoreInstanceRe } func fixtureCloneRequest(mods ...func(request mongodbflex.ApiCloneInstanceRequest)) mongodbflex.ApiCloneInstanceRequest { - request := testClient.CloneInstance(testCtx, testProjectId, testInstanceId) + request := testClient.CloneInstance(testCtx, testProjectId, testInstanceId, testRegion) request = request.CloneInstancePayload(mongodbflex.CloneInstancePayload{ Timestamp: utils.Ptr(testTimestamp), InstanceId: utils.Ptr(testBackupInstanceId), @@ -86,6 +87,7 @@ func fixtureCloneRequest(mods ...func(request mongodbflex.ApiCloneInstanceReques func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -105,21 +107,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -169,54 +171,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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 flag groups: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/mongodbflex/backup/schedule/schedule.go b/internal/cmd/mongodbflex/backup/schedule/schedule.go index b21167724..32d22a094 100644 --- a/internal/cmd/mongodbflex/backup/schedule/schedule.go +++ b/internal/cmd/mongodbflex/backup/schedule/schedule.go @@ -2,11 +2,13 @@ package schedule import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) const ( @@ -28,7 +29,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "schedule", Short: "Shows details of the backup schedule and retention policy of a MongoDB Flex instance", @@ -42,14 +43,14 @@ func NewCmd(p *print.Printer) *cobra.Command { `Get details of the backup schedule of a MongoDB Flex instance with ID "xxx" in JSON format`, "$ stackit mongodbflex backup schedule --instance-id xxx --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -61,7 +62,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read MongoDB Flex instance: %w", err) } - return outputResult(p, model.OutputFormat, resp.Item) + return outputResult(params.Printer, model.OutputFormat, resp.Item) }, } configureFlags(cmd) @@ -75,7 +76,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -86,20 +87,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { InstanceId: *flags.FlagToStringPointer(p, cmd, instanceIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiGetInstanceRequest { - req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) + req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId, model.Region) return req } @@ -126,24 +119,7 @@ func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.I output.WeeklySnapshotRetentionWeeks = (*instance.Options)["weeklySnapshotRetentionWeeks"] } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex backup schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(output, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex backup schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, output, func() error { table := tables.NewTable() table.AddRow("BACKUP SCHEDULE (UTC)", output.BackupSchedule) table.AddSeparator() @@ -164,5 +140,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.I } return nil - } + }) } diff --git a/internal/cmd/mongodbflex/backup/schedule/schedule_test.go b/internal/cmd/mongodbflex/backup/schedule/schedule_test.go index 018cefc28..a922ca4b6 100644 --- a/internal/cmd/mongodbflex/backup/schedule/schedule_test.go +++ b/internal/cmd/mongodbflex/backup/schedule/schedule_test.go @@ -4,17 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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 +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -25,8 +29,9 @@ var testInstanceId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -38,6 +43,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -49,7 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiGetInstanceRequest)) mongodbflex.ApiGetInstanceRequest { - request := testClient.GetInstance(testCtx, testProjectId, testInstanceId) + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId, testRegion) for _, mod := range mods { mod(&request) } @@ -59,6 +65,7 @@ func fixtureRequest(mods ...func(request *mongodbflex.ApiGetInstanceRequest)) mo func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +84,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -120,48 +127,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -218,7 +184,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go index 8f75c6964..9730a8621 100644 --- a/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go +++ b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go @@ -5,7 +5,11 @@ import ( "fmt" "strconv" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/client" mongoDBflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) const ( @@ -46,7 +49,7 @@ type inputModel struct { MonthlySnapshotRetentionMonths *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "update-schedule", Short: "Updates the backup schedule and retention policy for a MongoDB Flex instance", @@ -66,32 +69,30 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit mongodbflex backup update-schedule --instance-id xxx --store-for-days 5"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongoDBflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId) + instanceLabel, err := mongoDBflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = *model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update backup schedule of instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update backup schedule of instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Get current instance @@ -130,7 +131,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -158,7 +159,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { } func buildUpdateBackupScheduleRequest(ctx context.Context, model *inputModel, instance *mongodbflex.Instance, apiClient *mongodbflex.APIClient) mongodbflex.ApiUpdateBackupScheduleRequest { - req := apiClient.UpdateBackupSchedule(ctx, model.ProjectId, *model.InstanceId) + req := apiClient.UpdateBackupSchedule(ctx, model.ProjectId, *model.InstanceId, model.Region) payload := getUpdateBackupSchedulePayload(instance) @@ -228,6 +229,6 @@ func getUpdateBackupSchedulePayload(instance *mongodbflex.Instance) mongodbflex. } func buildGetInstanceRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiGetInstanceRequest { - req := apiClient.GetInstance(ctx, model.ProjectId, *model.InstanceId) + req := apiClient.GetInstance(ctx, model.ProjectId, *model.InstanceId, model.Region) return req } diff --git a/internal/cmd/mongodbflex/backup/update-schedule/update_schedule_test.go b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule_test.go index 1f6cc5aa0..649f46738 100644 --- a/internal/cmd/mongodbflex/backup/update-schedule/update_schedule_test.go +++ b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -13,7 +14,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" + testSchedule = "0 0/6 * * *" +) type testCtxKey struct{} @@ -21,13 +25,13 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &mongodbflex.APIClient{} var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() -var testSchedule = "0 0/6 * * *" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - scheduleFlag: testSchedule, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + scheduleFlag: testSchedule, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -39,10 +43,11 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: utils.Ptr(testInstanceId), - BackupSchedule: &testSchedule, + BackupSchedule: utils.Ptr(testSchedule), } for _, mod := range mods { mod(model) @@ -66,7 +71,7 @@ func fixturePayload(mods ...func(payload *mongodbflex.UpdateBackupSchedulePayloa } func fixtureUpdateBackupScheduleRequest(mods ...func(request *mongodbflex.ApiUpdateBackupScheduleRequest)) mongodbflex.ApiUpdateBackupScheduleRequest { - request := testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId) + request := testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId, testRegion) request = request.UpdateBackupSchedulePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -75,7 +80,7 @@ func fixtureUpdateBackupScheduleRequest(mods ...func(request *mongodbflex.ApiUpd } func fixtureGetInstanceRequest(mods ...func(request *mongodbflex.ApiGetInstanceRequest)) mongodbflex.ApiGetInstanceRequest { - request := testClient.GetInstance(testCtx, testProjectId, testInstanceId) + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId, testRegion) for _, mod := range mods { mod(&request) } @@ -84,7 +89,7 @@ func fixtureGetInstanceRequest(mods ...func(request *mongodbflex.ApiGetInstanceR func fixtureInstance(mods ...func(instance *mongodbflex.Instance)) *mongodbflex.Instance { instance := mongodbflex.Instance{ - BackupSchedule: &testSchedule, + BackupSchedule: utils.Ptr(testSchedule), Options: &map[string]string{ "dailySnapshotRetentionDays": "0", "weeklySnapshotRetentionWeeks": "3", @@ -102,6 +107,7 @@ func fixtureInstance(mods ...func(instance *mongodbflex.Instance)) *mongodbflex. func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -121,21 +127,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -171,45 +177,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) - 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(nil, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -260,6 +228,7 @@ func TestBuildUpdateBackupScheduleRequest(t *testing.T) { model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, }, InstanceId: utils.Ptr(testInstanceId), DailySnaphotRetentionDays: utils.Ptr(int64(2)), @@ -276,6 +245,7 @@ func TestBuildUpdateBackupScheduleRequest(t *testing.T) { model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, }, InstanceId: utils.Ptr(testInstanceId), BackupSchedule: utils.Ptr("0 0/6 5 2 1"), @@ -300,6 +270,7 @@ func TestBuildUpdateBackupScheduleRequest(t *testing.T) { model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, }, InstanceId: utils.Ptr(testInstanceId), }, diff --git a/internal/cmd/mongodbflex/instance/create/create.go b/internal/cmd/mongodbflex/instance/create/create.go index c6c4d3322..26e65e9a0 100644 --- a/internal/cmd/mongodbflex/instance/create/create.go +++ b/internal/cmd/mongodbflex/instance/create/create.go @@ -2,12 +2,15 @@ package create import ( "context" - "encoding/json" "errors" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -19,8 +22,6 @@ import ( mongodbflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" - "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex/wait" ) const ( @@ -56,7 +57,7 @@ type inputModel struct { Type *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a MongoDB Flex instance", @@ -73,37 +74,35 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a MongoDB Flex instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a MongoDB Flex instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Fill in version, if needed if model.Version == nil { - version, err := mongodbflexUtils.GetLatestMongoDBVersion(ctx, apiClient, model.ProjectId) + version, err := mongodbflexUtils.GetLatestMongoDBVersion(ctx, apiClient, model.ProjectId, model.Region) if err != nil { return fmt.Errorf("get latest MongoDB version: %w", err) } @@ -123,16 +122,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for MongoDB Flex instance creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) }, } configureFlags(cmd) @@ -157,7 +156,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -194,31 +193,23 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Type: utils.Ptr(flags.FlagWithDefaultToStringValue(p, cmd, typeFlag)), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, 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) + CreateInstance(ctx context.Context, projectId, region string) mongodbflex.ApiCreateInstanceRequest + ListFlavorsExecute(ctx context.Context, projectId, region string) (*mongodbflex.ListFlavorsResponse, error) + ListStoragesExecute(ctx context.Context, projectId, flavorId, region string) (*mongodbflex.ListStoragesResponse, error) } func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexClient) (mongodbflex.ApiCreateInstanceRequest, error) { - req := apiClient.CreateInstance(ctx, model.ProjectId) + req := apiClient.CreateInstance(ctx, model.ProjectId, model.Region) var flavorId *string var err error - flavors, err := apiClient.ListFlavorsExecute(ctx, model.ProjectId) + flavors, err := apiClient.ListFlavorsExecute(ctx, model.ProjectId, model.Region) if err != nil { return req, fmt.Errorf("get MongoDB Flex flavors: %w", err) } @@ -240,7 +231,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexC flavorId = model.FlavorId } - storages, err := apiClient.ListStoragesExecute(ctx, model.ProjectId, *flavorId) + storages, err := apiClient.ListStoragesExecute(ctx, model.ProjectId, *flavorId, model.Region) if err != nil { return req, fmt.Errorf("get MongoDB Flex storages: %w", err) } @@ -256,7 +247,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexC req = req.CreateInstancePayload(mongodbflex.CreateInstancePayload{ Name: model.InstanceName, - Acl: &mongodbflex.ACL{Items: model.ACL}, + Acl: &mongodbflex.CreateInstancePayloadAcl{Items: model.ACL}, BackupSchedule: model.BackupSchedule, FlavorId: flavorId, Replicas: &replicas, @@ -277,29 +268,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, projectLabe return fmt.Errorf("create instance response is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDBFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDBFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/mongodbflex/instance/create/create_test.go b/internal/cmd/mongodbflex/instance/create/create_test.go index 23fa109b7..acb37784c 100644 --- a/internal/cmd/mongodbflex/instance/create/create_test.go +++ b/internal/cmd/mongodbflex/instance/create/create_test.go @@ -5,8 +5,11 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -15,7 +18,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -29,18 +34,18 @@ type mongoDBFlexClientMocked struct { listStoragesResp *mongodbflex.ListStoragesResponse } -func (c *mongoDBFlexClientMocked) CreateInstance(ctx context.Context, projectId string) mongodbflex.ApiCreateInstanceRequest { - return testClient.CreateInstance(ctx, projectId) +func (c *mongoDBFlexClientMocked) CreateInstance(ctx context.Context, projectId, region string) mongodbflex.ApiCreateInstanceRequest { + return testClient.CreateInstance(ctx, projectId, region) } -func (c *mongoDBFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*mongodbflex.ListStoragesResponse, error) { +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) { +func (c *mongoDBFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*mongodbflex.ListFlavorsResponse, error) { if c.listFlavorsFails { return nil, fmt.Errorf("list flavors failed") } @@ -52,15 +57,16 @@ 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-perf4-mongodb", // Non-default - storageSizeFlag: "10", - versionFlag: "6.0", - typeFlag: "Replica", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceNameFlag: "example-name", + aclFlag: "0.0.0.0/0", + backupScheduleFlag: "0 0/6 * * *", + flavorIdFlag: testFlavorId, + storageClassFlag: "premium-perf4-mongodb", // Non-default + storageSizeFlag: "10", + versionFlag: "6.0", + typeFlag: "Replica", } for _, mod := range mods { mod(flagValues) @@ -72,6 +78,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceName: utils.Ptr("example-name"), @@ -90,7 +97,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiCreateInstanceRequest)) mongodbflex.ApiCreateInstanceRequest { - request := testClient.CreateInstance(testCtx, testProjectId) + request := testClient.CreateInstance(testCtx, testProjectId, testRegion) request = request.CreateInstancePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -101,7 +108,7 @@ func fixtureRequest(mods ...func(request *mongodbflex.ApiCreateInstanceRequest)) 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"})}, + Acl: &mongodbflex.CreateInstancePayloadAcl{Items: utils.Ptr([]string{"0.0.0.0/0"})}, BackupSchedule: utils.Ptr("0 0/6 * * *"), FlavorId: utils.Ptr(testFlavorId), Replicas: utils.Ptr(int64(3)), @@ -123,6 +130,7 @@ func fixturePayload(mods ...func(payload *mongodbflex.CreateInstancePayload)) mo func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -165,21 +173,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -249,56 +257,9 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + aclFlag: tt.aclValues, + }, tt.isValid) }) } } @@ -320,7 +281,7 @@ func TestBuildRequest(t *testing.T) { isValid: true, expectedRequest: fixtureRequest(), listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -348,7 +309,7 @@ func TestBuildRequest(t *testing.T) { isValid: true, expectedRequest: fixtureRequest(), listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -378,7 +339,7 @@ func TestBuildRequest(t *testing.T) { payload.Replicas = utils.Ptr(int64(1)) })), listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -403,7 +364,7 @@ func TestBuildRequest(t *testing.T) { payload.Replicas = utils.Ptr(int64(9)) })), listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -441,7 +402,7 @@ func TestBuildRequest(t *testing.T) { }, ), listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -476,7 +437,7 @@ func TestBuildRequest(t *testing.T) { }, ), listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -501,7 +462,7 @@ func TestBuildRequest(t *testing.T) { }, ), listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -573,7 +534,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.createInstanceResponse); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/instance/delete/delete.go b/internal/cmd/mongodbflex/instance/delete/delete.go index 237681df0..8c34e1533 100644 --- a/internal/cmd/mongodbflex/instance/delete/delete.go +++ b/internal/cmd/mongodbflex/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a MongoDB Flex instance", @@ -41,29 +43,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -75,20 +75,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region).WaitWithContext(ctx) + return err + }) 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" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -108,19 +108,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiDeleteInstanceRequest { - req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) + req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId, model.Region) return req } diff --git a/internal/cmd/mongodbflex/instance/delete/delete_test.go b/internal/cmd/mongodbflex/instance/delete/delete_test.go index ec3913381..52435d690 100644 --- a/internal/cmd/mongodbflex/instance/delete/delete_test.go +++ b/internal/cmd/mongodbflex/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiDeleteInstanceRequest)) mongodbflex.ApiDeleteInstanceRequest { - request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId) + request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId, testRegion) for _, mod := range mods { mod(&request) } @@ -101,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/mongodbflex/instance/describe/describe.go b/internal/cmd/mongodbflex/instance/describe/describe.go index 79ca55b64..c20de2606 100644 --- a/internal/cmd/mongodbflex/instance/describe/describe.go +++ b/internal/cmd/mongodbflex/instance/describe/describe.go @@ -2,11 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of a MongoDB Flex instance", @@ -46,12 +46,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read MongoDB Flex instance: %w", err) } - return outputResult(p, model.OutputFormat, resp.Item) + return outputResult(params.Printer, model.OutputFormat, resp.Item) }, } return cmd @@ -82,20 +82,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiGetInstanceRequest { - req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) + req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId, model.Region) return req } @@ -104,24 +96,7 @@ func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.I return fmt.Errorf("instance is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { var instanceType string if instance.HasReplicas() { var err error @@ -175,5 +150,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.I } return nil - } + }) } diff --git a/internal/cmd/mongodbflex/instance/describe/describe_test.go b/internal/cmd/mongodbflex/instance/describe/describe_test.go index 1238f7bc0..976e08e7f 100644 --- a/internal/cmd/mongodbflex/instance/describe/describe_test.go +++ b/internal/cmd/mongodbflex/instance/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -34,7 +39,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -57,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiGetInstanceRequest)) mongodbflex.ApiGetInstanceRequest { - request := testClient.GetInstance(testCtx, testProjectId, testInstanceId) + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId, testRegion) for _, mod := range mods { mod(&request) } @@ -101,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +144,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -241,7 +201,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/instance/instance.go b/internal/cmd/mongodbflex/instance/instance.go index 1a770bcc1..ee48a0632 100644 --- a/internal/cmd/mongodbflex/instance/instance.go +++ b/internal/cmd/mongodbflex/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for MongoDB Flex instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/mongodbflex/instance/list/list.go b/internal/cmd/mongodbflex/instance/list/list.go index 5b65f8d5e..74ac6bcb0 100644 --- a/internal/cmd/mongodbflex/instance/list/list.go +++ b/internal/cmd/mongodbflex/instance/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all MongoDB Flex instances", @@ -47,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 MongoDB Flex instances`, "$ stackit mongodbflex instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,23 +66,20 @@ func NewCmd(p *print.Printer) *cobra.Command { 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, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No instances found for project %q\n", projectLabel) - return nil + instances := utils.GetSliceFromPointer(resp.Items) + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } - instances := *resp.Items // Truncate output if model.Limit != nil && len(instances) > int(*model.Limit) { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) }, } @@ -94,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -113,42 +110,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiListInstancesRequest { - req := apiClient.ListInstances(ctx, model.ProjectId).Tag("") + req := apiClient.ListInstances(ctx, model.ProjectId, model.Region).Tag("") return req } -func outputResult(p *print.Printer, outputFormat string, instances []mongodbflex.InstanceListInstance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex instance list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []mongodbflex.InstanceListInstance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "STATUS") for i := range instances { @@ -165,5 +142,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []mongodbflex } return nil - } + }) } diff --git a/internal/cmd/mongodbflex/instance/list/list_test.go b/internal/cmd/mongodbflex/instance/list/list_test.go index e6a9c1416..d91c779f1 100644 --- a/internal/cmd/mongodbflex/instance/list/list_test.go +++ b/internal/cmd/mongodbflex/instance/list/list_test.go @@ -4,18 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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 +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -25,8 +29,9 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -38,6 +43,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, Limit: utils.Ptr(int64(10)), @@ -49,7 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiListInstancesRequest)) mongodbflex.ApiListInstancesRequest { - request := testClient.ListInstances(testCtx, testProjectId).Tag("") + request := testClient.ListInstances(testCtx, testProjectId, testRegion).Tag("") for _, mod := range mods { mod(&request) } @@ -59,6 +65,7 @@ func fixtureRequest(mods ...func(request *mongodbflex.ApiListInstancesRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +84,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -113,48 +120,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -190,6 +156,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string instanceList []mongodbflex.InstanceListInstance } tests := []struct { @@ -218,10 +185,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.instanceList); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instanceList); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/mongodbflex/instance/update/update.go b/internal/cmd/mongodbflex/instance/update/update.go index 5f854005c..e340f95d2 100644 --- a/internal/cmd/mongodbflex/instance/update/update.go +++ b/internal/cmd/mongodbflex/instance/update/update.go @@ -2,11 +2,11 @@ package update import ( "context" - "encoding/json" "errors" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -54,7 +54,7 @@ type inputModel struct { Type *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates a MongoDB Flex instance", @@ -71,29 +71,27 @@ func NewCmd(p *print.Printer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -109,16 +107,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for MongoDB Flex instance update: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, instanceLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, instanceLabel, resp) }, } configureFlags(cmd) @@ -186,32 +184,24 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Type: instanceType, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, 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) + PartialUpdateInstance(ctx context.Context, projectId, instanceId, region string) mongodbflex.ApiPartialUpdateInstanceRequest + GetInstanceExecute(ctx context.Context, projectId, instanceId, region string) (*mongodbflex.InstanceResponse, error) + ListFlavorsExecute(ctx context.Context, projectId, region string) (*mongodbflex.ListFlavorsResponse, error) + ListStoragesExecute(ctx context.Context, projectId, flavorId, region string) (*mongodbflex.ListStoragesResponse, error) } func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexClient) (mongodbflex.ApiPartialUpdateInstanceRequest, error) { - req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.InstanceId) + req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.InstanceId, model.Region) var flavorId *string var err error - flavors, err := apiClient.ListFlavorsExecute(ctx, model.ProjectId) + flavors, err := apiClient.ListFlavorsExecute(ctx, model.ProjectId, model.Region) if err != nil { return req, fmt.Errorf("get MongoDB Flex flavors: %w", err) } @@ -220,7 +210,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexC ram := model.RAM cpu := model.CPU if model.RAM == nil || model.CPU == nil { - currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.InstanceId) + currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.InstanceId, model.Region) if err != nil { return req, fmt.Errorf("get MongoDB Flex instance: %w", err) } @@ -251,13 +241,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexC if model.StorageClass != nil || model.StorageSize != nil { validationFlavorId := flavorId if validationFlavorId == nil { - currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.InstanceId) + currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.InstanceId, model.Region) 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) + storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *validationFlavorId, model.Region) if err != nil { return req, fmt.Errorf("get MongoDB Flex storages: %w", err) } @@ -312,29 +302,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, instanceLab return fmt.Errorf("resp is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal update MongoDBFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal update MongoDBFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Updated" if async { operationState = "Triggered update of" } p.Info("%s instance %q\n", operationState, instanceLabel) return nil - } + }) } diff --git a/internal/cmd/mongodbflex/instance/update/update_test.go b/internal/cmd/mongodbflex/instance/update/update_test.go index a36f33454..77df60874 100644 --- a/internal/cmd/mongodbflex/instance/update/update_test.go +++ b/internal/cmd/mongodbflex/instance/update/update_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -15,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -28,28 +32,28 @@ type mongoDBFlexClientMocked struct { listStoragesFails bool listStoragesResp *mongodbflex.ListStoragesResponse getInstanceFails bool - getInstanceResp *mongodbflex.GetInstanceResponse + getInstanceResp *mongodbflex.InstanceResponse } -func (c *mongoDBFlexClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId string) mongodbflex.ApiPartialUpdateInstanceRequest { - return testClient.PartialUpdateInstance(ctx, projectId, instanceId) +func (c *mongoDBFlexClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId, region string) mongodbflex.ApiPartialUpdateInstanceRequest { + return testClient.PartialUpdateInstance(ctx, projectId, instanceId, region) } -func (c *mongoDBFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*mongodbflex.GetInstanceResponse, error) { +func (c *mongoDBFlexClientMocked) GetInstanceExecute(_ context.Context, _, _, _ string) (*mongodbflex.InstanceResponse, 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) { +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) { +func (c *mongoDBFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*mongodbflex.ListFlavorsResponse, error) { if c.listFlavorsFails { return nil, fmt.Errorf("list flavors failed") } @@ -72,7 +76,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureRequiredFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -82,15 +87,16 @@ func fixtureRequiredFlagValues(mods ...func(flagValues map[string]string)) map[s 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 * * *", - storageClassFlag: "class", - storageSizeFlag: "10", - versionFlag: "5.0", - typeFlag: "Single", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + flavorIdFlag: testFlavorId, + instanceNameFlag: "example-name", + aclFlag: "0.0.0.0/0", + backupScheduleFlag: "0 0 * * *", + storageClassFlag: "class", + storageSizeFlag: "10", + versionFlag: "5.0", + typeFlag: "Single", } for _, mod := range mods { mod(flagValues) @@ -102,6 +108,7 @@ func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -116,6 +123,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -135,7 +143,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiPartialUpdateInstanceRequest)) mongodbflex.ApiPartialUpdateInstanceRequest { - request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId) + request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId, testRegion) request = request.PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{}) for _, mod := range mods { mod(&request) @@ -203,7 +211,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -211,7 +219,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -219,7 +227,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -280,7 +288,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -347,7 +355,7 @@ func TestBuildRequest(t *testing.T) { model *inputModel expectedRequest mongodbflex.ApiPartialUpdateInstanceRequest getInstanceFails bool - getInstanceResp *mongodbflex.GetInstanceResponse + getInstanceResp *mongodbflex.InstanceResponse listFlavorsFails bool listFlavorsResp *mongodbflex.ListFlavorsResponse listStoragesFails bool @@ -367,7 +375,7 @@ func TestBuildRequest(t *testing.T) { }), isValid: true, listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -375,7 +383,7 @@ func TestBuildRequest(t *testing.T) { }, }, }, - expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId, testRegion). PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ FlavorId: utils.Ptr(testFlavorId), }), @@ -388,7 +396,7 @@ func TestBuildRequest(t *testing.T) { }), isValid: true, listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -396,7 +404,7 @@ func TestBuildRequest(t *testing.T) { }, }, }, - expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId, testRegion). PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ FlavorId: utils.Ptr(testFlavorId), }), @@ -407,7 +415,7 @@ func TestBuildRequest(t *testing.T) { model.StorageClass = utils.Ptr("class") }), isValid: true, - getInstanceResp: &mongodbflex.GetInstanceResponse{ + getInstanceResp: &mongodbflex.InstanceResponse{ Item: &mongodbflex.Instance{ Flavor: &mongodbflex.Flavor{ Id: utils.Ptr(testFlavorId), @@ -421,7 +429,7 @@ func TestBuildRequest(t *testing.T) { Max: utils.Ptr(int64(100)), }, }, - expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId, testRegion). PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ Storage: &mongodbflex.Storage{ Class: utils.Ptr("class"), @@ -435,7 +443,7 @@ func TestBuildRequest(t *testing.T) { model.StorageSize = utils.Ptr(int64(10)) }), isValid: true, - getInstanceResp: &mongodbflex.GetInstanceResponse{ + getInstanceResp: &mongodbflex.InstanceResponse{ Item: &mongodbflex.Instance{ Flavor: &mongodbflex.Flavor{ Id: utils.Ptr(testFlavorId), @@ -449,7 +457,7 @@ func TestBuildRequest(t *testing.T) { Max: utils.Ptr(int64(100)), }, }, - expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId, testRegion). PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ Storage: &mongodbflex.Storage{ Class: utils.Ptr("class"), @@ -477,7 +485,7 @@ func TestBuildRequest(t *testing.T) { }, ), listFlavorsResp: &mongodbflex.ListFlavorsResponse{ - Flavors: &[]mongodbflex.HandlersInfraFlavor{ + Flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr(testFlavorId), Cpu: utils.Ptr(int64(2)), @@ -521,7 +529,7 @@ func TestBuildRequest(t *testing.T) { model.StorageClass = utils.Ptr("non-existing-class") }, ), - getInstanceResp: &mongodbflex.GetInstanceResponse{ + getInstanceResp: &mongodbflex.InstanceResponse{ Item: &mongodbflex.Instance{ Flavor: &mongodbflex.Flavor{ Id: utils.Ptr(testFlavorId), @@ -544,7 +552,7 @@ func TestBuildRequest(t *testing.T) { model.StorageSize = utils.Ptr(int64(9)) }, ), - getInstanceResp: &mongodbflex.GetInstanceResponse{ + getInstanceResp: &mongodbflex.InstanceResponse{ Item: &mongodbflex.Instance{ Flavor: &mongodbflex.Flavor{ Id: utils.Ptr(testFlavorId), @@ -617,7 +625,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/mongodbflex.go b/internal/cmd/mongodbflex/mongodbflex.go index e7373b9b9..3376477a3 100644 --- a/internal/cmd/mongodbflex/mongodbflex.go +++ b/internal/cmd/mongodbflex/mongodbflex.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/options" "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/user" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "mongodbflex", Short: "Provides functionality for MongoDB Flex", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(user.NewCmd(p)) - cmd.AddCommand(options.NewCmd(p)) - cmd.AddCommand(backup.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(user.NewCmd(params)) + cmd.AddCommand(options.NewCmd(params)) + cmd.AddCommand(backup.NewCmd(params)) } diff --git a/internal/cmd/mongodbflex/options/options.go b/internal/cmd/mongodbflex/options/options.go index 82b43cbc5..a49d3bc84 100644 --- a/internal/cmd/mongodbflex/options/options.go +++ b/internal/cmd/mongodbflex/options/options.go @@ -2,11 +2,13 @@ package options import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) const ( @@ -35,9 +36,9 @@ type inputModel struct { } type options struct { - Flavors *[]mongodbflex.HandlersInfraFlavor `json:"flavors,omitempty"` - Versions *[]string `json:"versions,omitempty"` - Storages *flavorStorages `json:"flavorStorages,omitempty"` + Flavors *[]mongodbflex.InstanceFlavor `json:"flavors,omitempty"` + Versions *[]string `json:"versions,omitempty"` + Storages *flavorStorages `json:"flavorStorages,omitempty"` } type flavorStorages struct { @@ -45,7 +46,7 @@ type flavorStorages struct { Storages *mongodbflex.ListStoragesResponse `json:"storages"` } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "options", Short: "Lists MongoDB Flex options", @@ -62,21 +63,21 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } // Call API - err = buildAndExecuteRequest(ctx, p, model, apiClient) + err = buildAndExecuteRequest(ctx, params.Printer, model, apiClient) if err != nil { return fmt.Errorf("get MongoDB Flex options: %w", err) } @@ -95,7 +96,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(flavorIdFlag, "", `The flavor ID to show storages for. Only relevant when "--storages" is passed`) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) flavors := flags.FlagToBoolValue(p, cmd, flavorsFlag) versions := flags.FlagToBoolValue(p, cmd, versionsFlag) @@ -123,22 +124,14 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { FlavorId: flags.FlagToStringPointer(p, cmd, flavorIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, 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) + ListFlavorsExecute(ctx context.Context, projectId, region string) (*mongodbflex.ListFlavorsResponse, error) + ListVersionsExecute(ctx context.Context, projectId, region string) (*mongodbflex.ListVersionsResponse, error) + ListStoragesExecute(ctx context.Context, projectId, flavorId, region string) (*mongodbflex.ListStoragesResponse, error) } func buildAndExecuteRequest(ctx context.Context, p *print.Printer, model *inputModel, apiClient mongoDBFlexOptionsClient) error { @@ -148,19 +141,19 @@ func buildAndExecuteRequest(ctx context.Context, p *print.Printer, model *inputM var err error if model.Flavors { - flavors, err = apiClient.ListFlavorsExecute(ctx, model.ProjectId) + flavors, err = apiClient.ListFlavorsExecute(ctx, model.ProjectId, model.Region) if err != nil { return fmt.Errorf("get MongoDB Flex flavors: %w", err) } } if model.Versions { - versions, err = apiClient.ListVersionsExecute(ctx, model.ProjectId) + versions, err = apiClient.ListVersionsExecute(ctx, model.ProjectId, model.Region) if err != nil { return fmt.Errorf("get MongoDB Flex versions: %w", err) } } if model.Storages { - storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *model.FlavorId) + storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *model.FlavorId, model.Region) if err != nil { return fmt.Errorf("get MongoDB Flex storages: %w", err) } @@ -188,25 +181,9 @@ func outputResult(p *print.Printer, model *inputModel, flavors *mongodbflex.List } } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(options, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex options: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(options, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex options: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, options, func() error { return outputResultAsTable(p, model, options) - } + }) } func outputResultAsTable(p *print.Printer, model *inputModel, options *options) error { @@ -223,7 +200,7 @@ func outputResultAsTable(p *print.Printer, model *inputModel, options *options) if model.Versions && len(*options.Versions) != 0 { content = append(content, buildVersionsTable(*options.Versions)) } - if model.Storages && options.Storages.Storages != nil && len(*options.Storages.Storages.StorageClasses) == 0 { + if model.Storages && options.Storages.Storages != nil && len(*options.Storages.Storages.StorageClasses) > 0 { content = append(content, buildStoragesTable(*options.Storages.Storages)) } @@ -235,7 +212,7 @@ func outputResultAsTable(p *print.Printer, model *inputModel, options *options) return nil } -func buildFlavorsTable(flavors []mongodbflex.HandlersInfraFlavor) tables.Table { +func buildFlavorsTable(flavors []mongodbflex.InstanceFlavor) tables.Table { table := tables.NewTable() table.SetTitle("Flavors") table.SetHeader("ID", "CPU", "MEMORY", "DESCRIPTION", "VALID INSTANCE TYPES") diff --git a/internal/cmd/mongodbflex/options/options_test.go b/internal/cmd/mongodbflex/options/options_test.go index 9b48a710b..3b42e4319 100644 --- a/internal/cmd/mongodbflex/options/options_test.go +++ b/internal/cmd/mongodbflex/options/options_test.go @@ -5,11 +5,13 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/google/go-cmp/cmp" "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) @@ -27,17 +29,17 @@ type mongoDBFlexClientMocked struct { listStoragesCalled bool } -func (c *mongoDBFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*mongodbflex.ListFlavorsResponse, error) { +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{}), + Flavors: utils.Ptr([]mongodbflex.InstanceFlavor{}), }), nil } -func (c *mongoDBFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*mongodbflex.ListVersionsResponse, error) { +func (c *mongoDBFlexClientMocked) ListVersionsExecute(_ context.Context, _, _ string) (*mongodbflex.ListVersionsResponse, error) { c.listVersionsCalled = true if c.listVersionsFails { return nil, fmt.Errorf("list versions failed") @@ -47,7 +49,7 @@ func (c *mongoDBFlexClientMocked) ListVersionsExecute(_ context.Context, _ strin }), nil } -func (c *mongoDBFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*mongodbflex.ListStoragesResponse, error) { +func (c *mongoDBFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, _ string) (*mongodbflex.ListStoragesResponse, error) { c.listStoragesCalled = true if c.listStoragesFails { return nil, fmt.Errorf("list storages failed") @@ -104,6 +106,7 @@ func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -166,46 +169,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -291,7 +255,7 @@ func TestBuildAndExecuteRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := &print.Printer{} - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) p.Cmd = cmd client := &mongoDBFlexClientMocked{ listFlavorsFails: tt.listFlavorsFails, @@ -406,7 +370,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.inputModel, tt.args.flavors, tt.args.versions, tt.args.storages); (err != nil) != tt.wantErr { @@ -455,7 +419,7 @@ func TestOutputResultAsTable(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResultAsTable(p, tt.args.model, tt.args.options); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/user/create/create.go b/internal/cmd/mongodbflex/user/create/create.go index be494fcdb..c25309c64 100644 --- a/internal/cmd/mongodbflex/user/create/create.go +++ b/internal/cmd/mongodbflex/user/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/client" mongodbflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) const ( @@ -39,7 +40,7 @@ type inputModel struct { Roles *[]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a MongoDB Flex user", @@ -58,31 +59,29 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit mongodbflex user create --instance-id xxx --role read --database default"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -93,7 +92,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } user := resp.Item - return outputResult(p, model.OutputFormat, instanceLabel, user) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, user) }, } @@ -102,18 +101,18 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - roleOptions := []string{"read", "readWrite"} + roleOptions := []string{"read", "readWrite", "readAnyDatabase", "readWriteAnyDatabase", "stackitAdmin"} 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, roleOptions...), roleFlag, fmt.Sprintf("Roles of the user, possible values are %q", roleOptions)) + cmd.Flags().Var(flags.EnumSliceFlag(false, rolesDefault, roleOptions...), roleFlag, fmt.Sprintf("Roles of the user, possible values are %q. The \"readAnyDatabase\", \"readWriteAnyDatabase\" and \"stackitAdmin\" roles will always be created in the admin database.", roleOptions)) err := flags.MarkFlagsRequired(cmd, instanceIdFlag, databaseFlag) cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -127,20 +126,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Roles: flags.FlagWithDefaultToStringSlicePointer(p, cmd, roleFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiCreateUserRequest { - req := apiClient.CreateUser(ctx, model.ProjectId, model.InstanceId) + req := apiClient.CreateUser(ctx, model.ProjectId, model.InstanceId, model.Region) req = req.CreateUserPayload(mongodbflex.CreateUserPayload{ Username: model.Username, Database: model.Database, @@ -154,24 +145,7 @@ func outputResult(p *print.Printer, outputFormat, instanceLabel string, user *mo return fmt.Errorf("user is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, user, func() error { p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, utils.PtrString(user.Id)) p.Outputf("Username: %s\n", utils.PtrString(user.Username)) p.Outputf("Password: %s\n", utils.PtrString(user.Password)) @@ -182,5 +156,5 @@ func outputResult(p *print.Printer, outputFormat, instanceLabel string, user *mo p.Outputf("URI: %s\n", utils.PtrString(user.Uri)) return nil - } + }) } diff --git a/internal/cmd/mongodbflex/user/create/create_test.go b/internal/cmd/mongodbflex/user/create/create_test.go index 73f184a65..e075bd29a 100644 --- a/internal/cmd/mongodbflex/user/create/create_test.go +++ b/internal/cmd/mongodbflex/user/create/create_test.go @@ -4,18 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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 +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -26,11 +30,12 @@ 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", - roleFlag: "read", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + usernameFlag: "johndoe", + databaseFlag: "default", + roleFlag: "read", } for _, mod := range mods { mod(flagValues) @@ -42,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -56,7 +62,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiCreateUserRequest)) mongodbflex.ApiCreateUserRequest { - request := testClient.CreateUser(testCtx, testProjectId, testInstanceId) + request := testClient.CreateUser(testCtx, testProjectId, testInstanceId, testRegion) request = request.CreateUserPayload(mongodbflex.CreateUserPayload{ Username: utils.Ptr("johndoe"), Database: utils.Ptr("default"), @@ -72,6 +78,7 @@ func fixtureRequest(mods ...func(request *mongodbflex.ApiCreateUserRequest)) mon func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -101,21 +108,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -161,48 +168,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -270,7 +236,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.user); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/user/delete/delete.go b/internal/cmd/mongodbflex/user/delete/delete.go index 7eec0fbd5..823f43d65 100644 --- a/internal/cmd/mongodbflex/user/delete/delete.go +++ b/internal/cmd/mongodbflex/user/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -31,7 +33,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", userIdArg), Short: "Deletes a MongoDB Flex user", @@ -47,35 +49,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, utils.ValidateUUID), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user name: %v", err) userLabel = model.UserId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -85,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete MongoDB Flex user: %w", err) } - p.Info("Deleted user %q of instance %q\n", userLabel, instanceLabel) + params.Printer.Info("Deleted user %q of instance %q\n", userLabel, instanceLabel) return nil }, } @@ -114,19 +114,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiDeleteUserRequest { - req := apiClient.DeleteUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + req := apiClient.DeleteUser(ctx, model.ProjectId, model.InstanceId, model.UserId, model.Region) return req } diff --git a/internal/cmd/mongodbflex/user/delete/delete_test.go b/internal/cmd/mongodbflex/user/delete/delete_test.go index 36e356790..e324f0010 100644 --- a/internal/cmd/mongodbflex/user/delete/delete_test.go +++ b/internal/cmd/mongodbflex/user/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -35,8 +37,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -48,6 +51,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -60,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiDeleteUserRequest)) mongodbflex.ApiDeleteUserRequest { - request := testClient.DeleteUser(testCtx, testProjectId, testInstanceId, testUserId) + request := testClient.DeleteUser(testCtx, testProjectId, testInstanceId, testUserId, testRegion) for _, mod := range mods { mod(&request) } @@ -104,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -164,54 +168,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/mongodbflex/user/describe/describe.go b/internal/cmd/mongodbflex/user/describe/describe.go index b94f4b453..038894cfc 100644 --- a/internal/cmd/mongodbflex/user/describe/describe.go +++ b/internal/cmd/mongodbflex/user/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +33,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", userIdArg), Short: "Shows details of a MongoDB Flex user", @@ -53,13 +53,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, utils.ValidateUUID), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -71,7 +71,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get MongoDB Flex user: %w", err) } - return outputResult(p, model.OutputFormat, *resp.Item) + return outputResult(params.Printer, model.OutputFormat, *resp.Item) }, } @@ -100,42 +100,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiGetUserRequest { - req := apiClient.GetUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + req := apiClient.GetUser(ctx, model.ProjectId, model.InstanceId, model.UserId, model.Region) return req } func outputResult(p *print.Printer, outputFormat string, user mongodbflex.InstanceResponseUser) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, user, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(user.Id)) table.AddSeparator() @@ -155,5 +130,5 @@ func outputResult(p *print.Printer, outputFormat string, user mongodbflex.Instan } return nil - } + }) } diff --git a/internal/cmd/mongodbflex/user/describe/describe_test.go b/internal/cmd/mongodbflex/user/describe/describe_test.go index 7d5201075..8ba06f13d 100644 --- a/internal/cmd/mongodbflex/user/describe/describe_test.go +++ b/internal/cmd/mongodbflex/user/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -35,8 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -48,6 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -60,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiGetUserRequest)) mongodbflex.ApiGetUserRequest { - request := testClient.GetUser(testCtx, testProjectId, testInstanceId, testUserId) + request := testClient.GetUser(testCtx, testProjectId, testInstanceId, testUserId, testRegion) for _, mod := range mods { mod(&request) } @@ -104,7 +111,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +119,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +127,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -164,54 +171,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -268,7 +228,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instanceResponseUser); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/user/list/list.go b/internal/cmd/mongodbflex/user/list/list.go index ae690568b..57d8530b1 100644 --- a/internal/cmd/mongodbflex/user/list/list.go +++ b/internal/cmd/mongodbflex/user/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +33,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all MongoDB Flex users of an instance", @@ -50,15 +50,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit mongodbflex user list --instance-id xxx --limit 10"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -69,23 +69,20 @@ func NewCmd(p *print.Printer) *cobra.Command { 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 { - p.Debug(print.ErrorLevel, "get instance name: %v", err) - instanceLabel = *model.InstanceId - } - p.Info("No users found for instance %q\n", instanceLabel) - return nil + users := utils.GetSliceFromPointer(resp.Items) + + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = *model.InstanceId } - users := *resp.Items // Truncate output if model.Limit != nil && len(users) > int(*model.Limit) { users = users[:*model.Limit] } - return outputResult(p, model.OutputFormat, users) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, users) }, } @@ -101,7 +98,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -121,42 +118,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiListUsersRequest { - req := apiClient.ListUsers(ctx, model.ProjectId, *model.InstanceId) + req := apiClient.ListUsers(ctx, model.ProjectId, *model.InstanceId, model.Region) return req } -func outputResult(p *print.Printer, outputFormat string, users []mongodbflex.ListUser) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(users, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex user list: %w", err) +func outputResult(p *print.Printer, outputFormat, instanceLabel string, users []mongodbflex.ListUser) error { + return p.OutputResult(outputFormat, users, func() error { + if len(users) == 0 { + p.Outputf("No users found for instance %q\n", instanceLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(users, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex user list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID", "USERNAME") for i := range users { @@ -172,5 +149,5 @@ func outputResult(p *print.Printer, outputFormat string, users []mongodbflex.Lis } return nil - } + }) } diff --git a/internal/cmd/mongodbflex/user/list/list_test.go b/internal/cmd/mongodbflex/user/list/list_test.go index 8cc901aef..aa7b42dd6 100644 --- a/internal/cmd/mongodbflex/user/list/list_test.go +++ b/internal/cmd/mongodbflex/user/list/list_test.go @@ -4,18 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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 +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -26,9 +30,10 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -40,6 +45,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: utils.Ptr(testInstanceId), @@ -52,7 +58,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiListUsersRequest)) mongodbflex.ApiListUsersRequest { - request := testClient.ListUsers(testCtx, testProjectId, testInstanceId) + request := testClient.ListUsers(testCtx, testProjectId, testInstanceId, testRegion) for _, mod := range mods { mod(&request) } @@ -62,6 +68,7 @@ func fixtureRequest(mods ...func(request *mongodbflex.ApiListUsersRequest)) mong func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -80,21 +87,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -130,48 +137,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -206,8 +172,9 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { - outputFormat string - users []mongodbflex.ListUser + outputFormat string + instanceLabel string + users []mongodbflex.ListUser } tests := []struct { name string @@ -235,10 +202,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.users); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.users); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/mongodbflex/user/reset-password/reset_password.go b/internal/cmd/mongodbflex/user/reset-password/reset_password.go index 5541b89a3..749b8f9d0 100644 --- a/internal/cmd/mongodbflex/user/reset-password/reset_password.go +++ b/internal/cmd/mongodbflex/user/reset-password/reset_password.go @@ -2,10 +2,10 @@ package resetpassword import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +33,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("reset-password %s", userIdArg), Short: "Resets the password of a MongoDB Flex user", @@ -49,35 +49,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, utils.ValidateUUID), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user name: %v", err) userLabel = model.UserId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to reset the password of user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to reset the password of user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -87,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("reset MongoDB Flex user password: %w", err) } - return outputResult(p, model.OutputFormat, userLabel, instanceLabel, user) + return outputResult(params.Printer, model.OutputFormat, userLabel, instanceLabel, user) }, } @@ -116,20 +114,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiResetUserRequest { - req := apiClient.ResetUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + req := apiClient.ResetUser(ctx, model.ProjectId, model.InstanceId, model.UserId, model.Region) return req } @@ -138,28 +128,11 @@ func outputResult(p *print.Printer, outputFormat, userLabel, instanceLabel strin return fmt.Errorf("user is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal MongoDB Flex reset password: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal MongoDB Flex reset password: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, user, func() error { p.Outputf("Reset password for user %q of instance %q\n\n", userLabel, instanceLabel) p.Outputf("Username: %s\n", utils.PtrString(user.Username)) p.Outputf("New password: %s\n", utils.PtrString(user.Password)) p.Outputf("New URI: %s\n", utils.PtrString(user.Uri)) return nil - } + }) } diff --git a/internal/cmd/mongodbflex/user/reset-password/reset_password_test.go b/internal/cmd/mongodbflex/user/reset-password/reset_password_test.go index 98abb2136..75a467eb9 100644 --- a/internal/cmd/mongodbflex/user/reset-password/reset_password_test.go +++ b/internal/cmd/mongodbflex/user/reset-password/reset_password_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -35,8 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -48,6 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -60,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiResetUserRequest)) mongodbflex.ApiResetUserRequest { - request := testClient.ResetUser(testCtx, testProjectId, testInstanceId, testUserId) + request := testClient.ResetUser(testCtx, testProjectId, testInstanceId, testUserId, testRegion) for _, mod := range mods { mod(&request) } @@ -104,7 +111,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +119,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +127,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -164,54 +171,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -270,7 +230,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.userLabel, tt.args.instanceLabel, tt.args.user); (err != nil) != tt.wantErr { diff --git a/internal/cmd/mongodbflex/user/update/update.go b/internal/cmd/mongodbflex/user/update/update.go index ee2a9ea1d..200873a77 100644 --- a/internal/cmd/mongodbflex/user/update/update.go +++ b/internal/cmd/mongodbflex/user/update/update.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -35,7 +37,7 @@ type inputModel struct { Roles *[]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", userIdArg), Short: "Updates a MongoDB Flex user", @@ -48,35 +50,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, utils.ValidateUUID), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user name: %v", err) userLabel = model.UserId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update user %q of instance %q?", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update user %q of instance %q?", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -86,7 +86,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update MongoDB Flex user: %w", err) } - p.Info("Updated user %q of instance %q\n", userLabel, instanceLabel) + params.Printer.Info("Updated user %q of instance %q\n", userLabel, instanceLabel) return nil }, } @@ -96,11 +96,11 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - roleOptions := []string{"read", "readWrite"} + roleOptions := []string{"read", "readWrite", "readAnyDatabase", "readWriteAnyDatabase", "stackitAdmin"} 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, roleOptions...), roleFlag, fmt.Sprintf("Roles of the user, possible values are %q", roleOptions)) + cmd.Flags().Var(flags.EnumSliceFlag(false, nil, roleOptions...), roleFlag, fmt.Sprintf("Roles of the user, possible values are %q. The \"readAnyDatabase\", \"readWriteAnyDatabase\" and \"stackitAdmin\" roles will always be created in the admin database.", roleOptions)) err := flags.MarkFlagsRequired(cmd, instanceIdFlag) cobra.CheckErr(err) @@ -129,20 +129,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Roles: roles, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiPartialUpdateUserRequest { - req := apiClient.PartialUpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + req := apiClient.PartialUpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId, model.Region) req = req.PartialUpdateUserPayload(mongodbflex.PartialUpdateUserPayload{ Database: model.Database, Roles: model.Roles, diff --git a/internal/cmd/mongodbflex/user/update/update_test.go b/internal/cmd/mongodbflex/user/update/update_test.go index fc2872c2d..0d110a06c 100644 --- a/internal/cmd/mongodbflex/user/update/update_test.go +++ b/internal/cmd/mongodbflex/user/update/update_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +14,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu02" +) type testCtxKey struct{} @@ -36,9 +38,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, - databaseFlag: "default", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + instanceIdFlag: testInstanceId, + databaseFlag: "default", } for _, mod := range mods { mod(flagValues) @@ -50,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, InstanceId: testInstanceId, @@ -63,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *mongodbflex.ApiPartialUpdateUserRequest)) mongodbflex.ApiPartialUpdateUserRequest { - request := testClient.PartialUpdateUser(testCtx, testProjectId, testInstanceId, testUserId) + request := testClient.PartialUpdateUser(testCtx, testProjectId, testInstanceId, testUserId, testRegion) request = request.PartialUpdateUserPayload(mongodbflex.PartialUpdateUserPayload{ Database: utils.Ptr("default"), }) @@ -127,7 +131,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -135,7 +139,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -143,7 +147,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -196,54 +200,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/mongodbflex/user/user.go b/internal/cmd/mongodbflex/user/user.go index 614b7c2f9..ed76dffbf 100644 --- a/internal/cmd/mongodbflex/user/user.go +++ b/internal/cmd/mongodbflex/user/user.go @@ -8,13 +8,13 @@ import ( resetpassword "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/user/reset-password" "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex/user/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "user", Short: "Provides functionality for MongoDB Flex users", @@ -22,15 +22,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(resetpassword.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(resetpassword.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/network-area/create/create.go b/internal/cmd/network-area/create/create.go index 4ce917af7..b75fa6634 100644 --- a/internal/cmd/network-area/create/create.go +++ b/internal/cmd/network-area/create/create.go @@ -2,11 +2,17 @@ package create import ( "context" - "encoding/json" "fmt" + "os" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" - "github.com/goccy/go-yaml" "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -14,38 +20,59 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" rmClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" rmUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) const ( - nameFlag = "name" - organizationIdFlag = "organization-id" - dnsNameServersFlag = "dns-name-servers" - networkRangesFlag = "network-ranges" - transferNetworkFlag = "transfer-network" + nameFlag = "name" + organizationIdFlag = "organization-id" + // Deprecated: dnsNameServersFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + dnsNameServersFlag = "dns-name-servers" + // Deprecated: networkRangesFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + networkRangesFlag = "network-ranges" + // Deprecated: transferNetworkFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + transferNetworkFlag = "transfer-network" + // Deprecated: defaultPrefixLengthFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. defaultPrefixLengthFlag = "default-prefix-length" - maxPrefixLengthFlag = "max-prefix-length" - minPrefixLengthFlag = "min-prefix-length" - labelFlag = "labels" + // Deprecated: maxPrefixLengthFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + maxPrefixLengthFlag = "max-prefix-length" + // Deprecated: minPrefixLengthFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + minPrefixLengthFlag = "min-prefix-length" + labelFlag = "labels" + + deprecationMessage = "Deprecated and will be removed after April 2026. Use instead the new command `$ stackit network-area region` to configure these options for a network area." ) type inputModel struct { *globalflags.GlobalFlagModel - Name *string - OrganizationId *string - DnsNameServers *[]string - NetworkRanges *[]string - TransferNetwork *string + Name *string + OrganizationId string + // Deprecated: DnsNameServers is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + DnsNameServers *[]string + // Deprecated: NetworkRanges is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + NetworkRanges *[]string + // Deprecated: TransferNetwork is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + TransferNetwork *string + // Deprecated: DefaultPrefixLength is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. DefaultPrefixLength *int64 - MaxPrefixLength *int64 - MinPrefixLength *int64 - Labels *map[string]string + // Deprecated: MaxPrefixLength is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + MaxPrefixLength *int64 + // Deprecated: MinPrefixLength is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + MinPrefixLength *int64 + Labels *map[string]string +} + +// NetworkAreaResponses is a workaround, to keep the two responses of the iaas v2 api together for the json and yaml output +// Should be removed when the deprecated flags are removed +type NetworkAreaResponses struct { + NetworkArea iaas.NetworkArea `json:"network_area"` + RegionalArea *iaas.RegionalArea `json:"regional_area"` } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a STACKIT Network Area (SNA)", @@ -53,55 +80,45 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `Create a network area with name "network-area-1" in organization with ID "xxx" with network ranges and a transfer network`, - `$ stackit network-area create --name network-area-1 --organization-id xxx --network-ranges "1.1.1.0/24,192.123.1.0/24" --transfer-network "192.160.0.0/24"`, + `Create a network area with name "network-area-1" in organization with ID "xxx"`, + `$ stackit network-area create --name network-area-1 --organization-id xxx"`, ), examples.NewExample( - `Create a network area with name "network-area-2" in organization with ID "xxx" with network ranges, transfer network and DNS name server`, - `$ stackit network-area create --name network-area-2 --organization-id xxx --network-ranges "1.1.1.0/24,192.123.1.0/24" --transfer-network "192.160.0.0/24" --dns-name-servers "1.1.1.1"`, - ), - examples.NewExample( - `Create a network area with name "network-area-3" in organization with ID "xxx" with network ranges, transfer network and additional options`, - `$ stackit network-area create --name network-area-3 --organization-id xxx --network-ranges "1.1.1.0/24,192.123.1.0/24" --transfer-network "192.160.0.0/24" --default-prefix-length 25 --max-prefix-length 29 --min-prefix-length 24`, - ), - examples.NewExample( - `Create a network area with name "network-area-1" in organization with ID "xxx" with network ranges and a transfer network and labels "key=value,key1=value1"`, - `$ stackit network-area create --name network-area-1 --organization-id xxx --network-ranges "1.1.1.0/24,192.123.1.0/24" --transfer-network "192.160.0.0/24" --labels key=value,key1=value1`, + `Create a network area with name "network-area-1" in organization with ID "xxx" with labels "key=value,key1=value1"`, + `$ stackit network-area create --name network-area-1 --organization-id xxx --labels key=value,key1=value1`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } var orgLabel string - rmApiClient, err := rmClient.ConfigureClient(p) + rmApiClient, err := rmClient.ConfigureClient(params.Printer, params.CliVersion) if err == nil { - orgLabel, err = rmUtils.GetOrganizationName(ctx, rmApiClient, *model.OrganizationId) + orgLabel, err = rmUtils.GetOrganizationName(ctx, rmApiClient, model.OrganizationId) if err != nil { - p.Debug(print.ErrorLevel, "get organization name: %v", err) - orgLabel = *model.OrganizationId + params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err) + orgLabel = model.OrganizationId } else if orgLabel == "" { - orgLabel = *model.OrganizationId + orgLabel = model.OrganizationId } } else { - p.Debug(print.ErrorLevel, "configure resource manager client: %v", err) + params.Printer.Debug(print.ErrorLevel, "configure resource manager client: %v", err) } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a network area for organization %q?", orgLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a network area for organization %q?", orgLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -110,8 +127,38 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("create network area: %w", err) } + if resp == nil || resp.Id == nil { + return fmt.Errorf("create network area: empty response") + } + + responses := &NetworkAreaResponses{ + NetworkArea: *resp, + } + + if hasDeprecatedFlagsSet(model) { + deprecatedFlags := getConfiguredDeprecatedFlags(model) + params.Printer.Warn("the flags %q are deprecated and will be removed after April 2026. Use `$ stackit network-area region` to configure these options for a network area.\n", strings.Join(deprecatedFlags, ",")) + if resp == nil || resp.Id == nil { + return fmt.Errorf("create network area: empty response") + } + reqNetworkArea := buildRequestNetworkAreaRegion(ctx, model, *resp.Id, apiClient) + respNetworkArea, err := reqNetworkArea.Execute() + if err != nil { + return fmt.Errorf("create network area region: %w", err) + } + if !model.Async { + err := spinner.Run(params.Printer, "Create network area region", func() error { + _, err = wait.CreateNetworkAreaRegionWaitHandler(ctx, apiClient, model.OrganizationId, *resp.Id, model.Region).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for creating network area region %w", err) + } + } + responses.RegionalArea = respNetworkArea + } - return outputResult(p, model.OutputFormat, orgLabel, resp) + return outputResult(params.Printer, model.OutputFormat, orgLabel, responses) }, } configureFlags(cmd) @@ -121,25 +168,64 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(nameFlag, "n", "", "Network area name") cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network-area. E.g. '--labels key1=value1,key2=value2,...'") cmd.Flags().StringSlice(dnsNameServersFlag, nil, "List of DNS name server IPs") cmd.Flags().Var(flags.CIDRSliceFlag(), networkRangesFlag, "List of network ranges") cmd.Flags().Var(flags.CIDRFlag(), transferNetworkFlag, "Transfer network in CIDR notation") cmd.Flags().Int64(defaultPrefixLengthFlag, 0, "The default prefix length for networks in the network area") cmd.Flags().Int64(maxPrefixLengthFlag, 0, "The maximum prefix length for networks in the network area") cmd.Flags().Int64(minPrefixLengthFlag, 0, "The minimum prefix length for networks in the network area") - cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network-area. E.g. '--labels key1=value1,key2=value2,...'") - err := flags.MarkFlagsRequired(cmd, nameFlag, organizationIdFlag, networkRangesFlag, transferNetworkFlag) + cobra.CheckErr(cmd.Flags().MarkDeprecated(dnsNameServersFlag, deprecationMessage)) + cobra.CheckErr(cmd.Flags().MarkDeprecated(networkRangesFlag, deprecationMessage)) + cobra.CheckErr(cmd.Flags().MarkDeprecated(transferNetworkFlag, deprecationMessage)) + cobra.CheckErr(cmd.Flags().MarkDeprecated(defaultPrefixLengthFlag, deprecationMessage)) + cobra.CheckErr(cmd.Flags().MarkDeprecated(maxPrefixLengthFlag, deprecationMessage)) + cobra.CheckErr(cmd.Flags().MarkDeprecated(minPrefixLengthFlag, deprecationMessage)) + // Set the output for deprecation warnings to stderr + cmd.Flags().SetOutput(os.Stderr) + + cmd.MarkFlagsRequiredTogether(networkRangesFlag, transferNetworkFlag) + + err := flags.MarkFlagsRequired(cmd, nameFlag, organizationIdFlag) cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func hasDeprecatedFlagsSet(model *inputModel) bool { + deprecatedFlags := getConfiguredDeprecatedFlags(model) + return len(deprecatedFlags) > 0 +} + +func getConfiguredDeprecatedFlags(model *inputModel) []string { + var result []string + if model.DnsNameServers != nil { + result = append(result, dnsNameServersFlag) + } + if model.NetworkRanges != nil { + result = append(result, networkRangesFlag) + } + if model.TransferNetwork != nil { + result = append(result, transferNetworkFlag) + } + if model.DefaultPrefixLength != nil { + result = append(result, defaultPrefixLengthFlag) + } + if model.MaxPrefixLength != nil { + result = append(result, maxPrefixLengthFlag) + } + if model.MinPrefixLength != nil { + result = append(result, minPrefixLengthFlag) + } + return result +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) model := inputModel{ GlobalFlagModel: globalFlags, Name: flags.FlagToStringPointer(p, cmd, nameFlag), - OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), DnsNameServers: flags.FlagToStringSlicePointer(p, cmd, dnsNameServersFlag), NetworkRanges: flags.FlagToStringSlicePointer(p, cmd, networkRangesFlag), TransferNetwork: flags.FlagToStringPointer(p, cmd, transferNetworkFlag), @@ -149,78 +235,71 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + // Check if any of the deprecated **optional** fields are set and if no of the associated deprecated **required** fields is set. + hasAllRequiredRegionalAreaFieldsSet := model.NetworkRanges != nil && model.TransferNetwork != nil + hasOptionalRegionalAreaFieldsSet := model.DnsNameServers != nil || model.DefaultPrefixLength != nil || model.MaxPrefixLength != nil || model.MinPrefixLength != nil + if hasOptionalRegionalAreaFieldsSet && !hasAllRequiredRegionalAreaFieldsSet { + return nil, &cliErr.MultipleFlagsAreMissing{ + MissingFlags: []string{networkRangesFlag, transferNetworkFlag}, + SetFlags: []string{dnsNameServersFlag, defaultPrefixLengthFlag, minPrefixLengthFlag, maxPrefixLengthFlag}, } } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateNetworkAreaRequest { - req := apiClient.CreateNetworkArea(ctx, *model.OrganizationId) + req := apiClient.CreateNetworkArea(ctx, model.OrganizationId) - networkRanges := make([]iaas.NetworkRange, len(*model.NetworkRanges)) - for i, networkRange := range *model.NetworkRanges { - networkRanges[i] = iaas.NetworkRange{ - Prefix: utils.Ptr(networkRange), - } + payload := iaas.CreateNetworkAreaPayload{ + Name: model.Name, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v + return req.CreateNetworkAreaPayload(payload) +} + +func buildRequestNetworkAreaRegion(ctx context.Context, model *inputModel, networkAreaId string, apiClient *iaas.APIClient) iaas.ApiCreateNetworkAreaRegionRequest { + req := apiClient.CreateNetworkAreaRegion(ctx, model.OrganizationId, networkAreaId, model.Region) + + var networkRanges []iaas.NetworkRange + if model.NetworkRanges != nil { + networkRanges = make([]iaas.NetworkRange, len(*model.NetworkRanges)) + for i, networkRange := range *model.NetworkRanges { + networkRanges[i] = iaas.NetworkRange{ + Prefix: utils.Ptr(networkRange), + } } } - payload := iaas.CreateNetworkAreaPayload{ - Name: model.Name, - Labels: labelsMap, - AddressFamily: &iaas.CreateAreaAddressFamily{ - Ipv4: &iaas.CreateAreaIPv4{ - DefaultNameservers: model.DnsNameServers, - NetworkRanges: utils.Ptr(networkRanges), - TransferNetwork: model.TransferNetwork, - DefaultPrefixLen: model.DefaultPrefixLength, - MaxPrefixLen: model.MaxPrefixLength, - MinPrefixLen: model.MinPrefixLength, - }, + payload := iaas.CreateNetworkAreaRegionPayload{ + Ipv4: &iaas.RegionalAreaIPv4{ + DefaultNameservers: model.DnsNameServers, + NetworkRanges: utils.Ptr(networkRanges), + TransferNetwork: model.TransferNetwork, + DefaultPrefixLen: model.DefaultPrefixLength, + MaxPrefixLen: model.MaxPrefixLength, + MinPrefixLen: model.MinPrefixLength, }, } - return req.CreateNetworkAreaPayload(payload) + return req.CreateNetworkAreaRegionPayload(payload) } -func outputResult(p *print.Printer, outputFormat, orgLabel string, networkArea *iaas.NetworkArea) error { - if networkArea == nil { +func outputResult(p *print.Printer, outputFormat, orgLabel string, responses *NetworkAreaResponses) error { + if responses == nil { return fmt.Errorf("network area is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(networkArea, "", " ") - if err != nil { - return fmt.Errorf("marshal network area: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(networkArea, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network area: %w", err) - } - p.Outputln(string(details)) + prettyOutputFunc := func() error { + p.Outputf("Created STACKIT Network Area for organization %q.\nNetwork area ID: %s\n", orgLabel, utils.PtrString(responses.NetworkArea.Id)) return nil - default: - p.Outputf("Created STACKIT Network Area for organization %q.\nNetwork area ID: %s\n", orgLabel, utils.PtrString(networkArea.AreaId)) - return nil } + // If RegionalArea is NOT set in the response, then no deprecated Flags were set. + // In this case, only the response of NetworkArea should be printed in JSON and yaml output, to avoid breaking changes after the deprecated fields are removed + if responses.RegionalArea == nil { + return p.OutputResult(outputFormat, responses.NetworkArea, prettyOutputFunc) + } + return p.OutputResult(outputFormat, responses, prettyOutputFunc) } diff --git a/internal/cmd/network-area/create/create_test.go b/internal/cmd/network-area/create/create_test.go index ee055ad5c..b903ccbba 100644 --- a/internal/cmd/network-area/create/create_test.go +++ b/internal/cmd/network-area/create/create_test.go @@ -2,10 +2,15 @@ package create import ( "context" + "strconv" + "strings" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,24 +19,34 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" + testName = "example-network-area-name" + testTransferNetwork = "100.0.0.0/24" + testDefaultPrefixLength int64 = 25 + testMaxPrefixLength int64 = 26 + testMinPrefixLength int64 = 24 +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &iaas.APIClient{} -var testOrgId = uuid.NewString() +var ( + testOrgId = uuid.NewString() + testAreaId = uuid.NewString() + testDnsNameservers = []string{"1.1.1.0", "1.1.2.0"} + testNetworkRanges = []string{"192.0.0.0/24", "102.0.0.0/24"} +) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - nameFlag: "example-network-area-name", - organizationIdFlag: testOrgId, - dnsNameServersFlag: "1.1.1.0,1.1.2.0", - networkRangesFlag: "192.0.0.0/24,102.0.0.0/24", - transferNetworkFlag: "100.0.0.0/24", - defaultPrefixLengthFlag: "24", - maxPrefixLengthFlag: "24", - minPrefixLengthFlag: "24", - labelFlag: "key=value", + globalflags.RegionFlag: testRegion, + + nameFlag: testName, + organizationIdFlag: testOrgId, + labelFlag: "key=value", } for _, mod := range mods { mod(flagValues) @@ -43,15 +58,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - Name: utils.Ptr("example-network-area-name"), - OrganizationId: utils.Ptr(testOrgId), - DnsNameServers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}), - NetworkRanges: utils.Ptr([]string{"192.0.0.0/24", "102.0.0.0/24"}), - TransferNetwork: utils.Ptr("100.0.0.0/24"), - DefaultPrefixLength: utils.Ptr(int64(24)), - MaxPrefixLength: utils.Ptr(int64(24)), - MinPrefixLength: utils.Ptr(int64(24)), + Name: utils.Ptr("example-network-area-name"), + OrganizationId: testOrgId, Labels: utils.Ptr(map[string]string{ "key": "value", }), @@ -77,23 +87,40 @@ func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaPayload)) iaas.C Labels: utils.Ptr(map[string]interface{}{ "key": "value", }), - AddressFamily: &iaas.CreateAreaAddressFamily{ - Ipv4: &iaas.CreateAreaIPv4{ - DefaultNameservers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}), - NetworkRanges: &[]iaas.NetworkRange{ - { - Prefix: utils.Ptr("192.0.0.0/24"), - }, - { - Prefix: utils.Ptr("102.0.0.0/24"), - }, - }, - TransferNetwork: utils.Ptr("100.0.0.0/24"), - DefaultPrefixLen: utils.Ptr(int64(24)), - MaxPrefixLen: utils.Ptr(int64(24)), - MinPrefixLen: utils.Ptr(int64(24)), - }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequestRegionalArea(mods ...func(request *iaas.ApiCreateNetworkAreaRegionRequest)) iaas.ApiCreateNetworkAreaRegionRequest { + req := testClient.CreateNetworkAreaRegion(testCtx, testOrgId, testAreaId, testRegion) + req = req.CreateNetworkAreaRegionPayload(fixtureRegionalAreaPayload()) + for _, mod := range mods { + mod(&req) + } + return req +} + +func fixtureRegionalAreaPayload(mods ...func(request *iaas.CreateNetworkAreaRegionPayload)) iaas.CreateNetworkAreaRegionPayload { + var networkRanges []iaas.NetworkRange + for _, networkRange := range testNetworkRanges { + networkRanges = append(networkRanges, iaas.NetworkRange{ + Prefix: utils.Ptr(networkRange), + }) + } + + payload := iaas.CreateNetworkAreaRegionPayload{ + Ipv4: &iaas.RegionalAreaIPv4{ + DefaultNameservers: utils.Ptr(testDnsNameservers), + DefaultPrefixLen: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLen: utils.Ptr(testMaxPrefixLength), + MinPrefixLen: utils.Ptr(testMinPrefixLength), + NetworkRanges: utils.Ptr(networkRanges), + TransferNetwork: utils.Ptr(testTransferNetwork), }, + Status: nil, } for _, mod := range mods { mod(&payload) @@ -104,6 +131,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaPayload)) iaas.C func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -116,20 +144,35 @@ func TestParseInput(t *testing.T) { expectedModel: fixtureInputModel(), }, { - description: "required only", - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, dnsNameServersFlag) - delete(flagValues, defaultPrefixLengthFlag) - delete(flagValues, maxPrefixLengthFlag) - delete(flagValues, minPrefixLengthFlag) - }), + description: "with deprecated flags", + flagValues: map[string]string{ + nameFlag: testName, + organizationIdFlag: testOrgId, + + // Deprecated flags + dnsNameServersFlag: strings.Join(testDnsNameservers, ","), + networkRangesFlag: strings.Join(testNetworkRanges, ","), + transferNetworkFlag: testTransferNetwork, + defaultPrefixLengthFlag: strconv.FormatInt(testDefaultPrefixLength, 10), + maxPrefixLengthFlag: strconv.FormatInt(testMaxPrefixLength, 10), + minPrefixLengthFlag: strconv.FormatInt(testMinPrefixLength, 10), + }, isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.DnsNameServers = nil - model.DefaultPrefixLength = nil - model.MaxPrefixLength = nil - model.MinPrefixLength = nil - }), + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: testOrgId, + + // Deprecated fields + DnsNameServers: utils.Ptr(testDnsNameservers), + NetworkRanges: utils.Ptr(testNetworkRanges), + TransferNetwork: utils.Ptr(testTransferNetwork), + DefaultPrefixLength: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLength: utils.Ptr(testMaxPrefixLength), + MinPrefixLength: utils.Ptr(testMinPrefixLength), + }, }, { description: "name missing", @@ -139,16 +182,38 @@ func TestParseInput(t *testing.T) { isValid: false, }, { - description: "network ranges missing", + description: "set deprecated network ranges - missing transfer network", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkRangesFlag] = strings.Join(testNetworkRanges, ",") + }), + isValid: false, + }, + { + description: "set deprecated transfer network - missing network ranges", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, networkRangesFlag) + flagValues[transferNetworkFlag] = testTransferNetwork }), isValid: false, }, { - description: "transfer network missing", + description: "set deprecated transfer network and network ranges", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkRangesFlag] = strings.Join(testNetworkRanges, ",") + flagValues[transferNetworkFlag] = testTransferNetwork + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NetworkRanges = utils.Ptr(testNetworkRanges) + model.TransferNetwork = utils.Ptr(testTransferNetwork) + }), + }, + { + description: "set deprecated optional flags", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, transferNetworkFlag) + flagValues[dnsNameServersFlag] = strings.Join(testDnsNameservers, ",") + flagValues[defaultPrefixLengthFlag] = strconv.FormatInt(testDefaultPrefixLength, 10) + flagValues[maxPrefixLengthFlag] = strconv.FormatInt(testMaxPrefixLength, 10) + flagValues[minPrefixLengthFlag] = strconv.FormatInt(testMinPrefixLength, 10) }), isValid: false, }, @@ -192,46 +257,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -264,11 +290,63 @@ func TestBuildRequest(t *testing.T) { } } +func TestBuildRequestNetworkAreaRegion(t *testing.T) { + tests := []struct { + description string + model *inputModel + areaId string + expectedRequest iaas.ApiCreateNetworkAreaRegionRequest + }{ + { + description: "base", + model: fixtureInputModel(func(model *inputModel) { + // Deprecated fields + model.DnsNameServers = utils.Ptr(testDnsNameservers) + model.NetworkRanges = utils.Ptr(testNetworkRanges) + model.TransferNetwork = utils.Ptr(testTransferNetwork) + model.DefaultPrefixLength = utils.Ptr(testDefaultPrefixLength) + model.MaxPrefixLength = utils.Ptr(testMaxPrefixLength) + model.MinPrefixLength = utils.Ptr(testMinPrefixLength) + }), + areaId: testAreaId, + expectedRequest: fixtureRequestRegionalArea(), + }, + { + description: "base without network ranges", + model: fixtureInputModel(func(model *inputModel) { + // Deprecated fields + model.DnsNameServers = utils.Ptr(testDnsNameservers) + model.NetworkRanges = utils.Ptr(testNetworkRanges) + model.TransferNetwork = utils.Ptr(testTransferNetwork) + model.DefaultPrefixLength = utils.Ptr(testDefaultPrefixLength) + model.MaxPrefixLength = utils.Ptr(testMaxPrefixLength) + model.MinPrefixLength = utils.Ptr(testMinPrefixLength) + }), + areaId: testAreaId, + expectedRequest: fixtureRequestRegionalArea(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequestNetworkAreaRegion(testCtx, tt.model, testAreaId, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + func Test_outputResult(t *testing.T) { type args struct { outputFormat string orgLabel string - networkArea *iaas.NetworkArea + responses *NetworkAreaResponses } tests := []struct { name string @@ -280,21 +358,161 @@ func Test_outputResult(t *testing.T) { args: args{}, wantErr: true, }, + { + name: "set empty response", + args: args{ + responses: &NetworkAreaResponses{}, + }, + wantErr: false, + }, { name: "set empty network area", args: args{ - networkArea: &iaas.NetworkArea{}, + responses: &NetworkAreaResponses{ + NetworkArea: iaas.NetworkArea{}, + }, }, wantErr: false, }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.orgLabel, tt.args.networkArea); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.orgLabel, tt.args.responses); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) } } + +func TestGetConfiguredDeprecatedFlags(t *testing.T) { + type args struct { + model *inputModel + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "no deprecated flags", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: testOrgId, + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + DnsNameServers: nil, + NetworkRanges: nil, + TransferNetwork: nil, + DefaultPrefixLength: nil, + MaxPrefixLength: nil, + MinPrefixLength: nil, + }, + }, + want: nil, + }, + { + name: "deprecated flags", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: testOrgId, + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + DnsNameServers: utils.Ptr(testDnsNameservers), + NetworkRanges: utils.Ptr(testNetworkRanges), + TransferNetwork: utils.Ptr(testTransferNetwork), + DefaultPrefixLength: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLength: utils.Ptr(testMaxPrefixLength), + MinPrefixLength: utils.Ptr(testMinPrefixLength), + }, + }, + want: []string{dnsNameServersFlag, networkRangesFlag, transferNetworkFlag, defaultPrefixLengthFlag, minPrefixLengthFlag, maxPrefixLengthFlag}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getConfiguredDeprecatedFlags(tt.args.model) + + less := func(a, b string) bool { + return a < b + } + if diff := cmp.Diff(tt.want, got, cmpopts.SortSlices(less)); diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestHasDeprecatedFlagsSet(t *testing.T) { + type args struct { + model *inputModel + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "no deprecated flags", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: testOrgId, + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + DnsNameServers: nil, + NetworkRanges: nil, + TransferNetwork: nil, + DefaultPrefixLength: nil, + MaxPrefixLength: nil, + MinPrefixLength: nil, + }, + }, + want: false, + }, + { + name: "deprecated flags", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: testOrgId, + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + DnsNameServers: utils.Ptr(testDnsNameservers), + NetworkRanges: utils.Ptr(testNetworkRanges), + TransferNetwork: utils.Ptr(testTransferNetwork), + DefaultPrefixLength: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLength: utils.Ptr(testMaxPrefixLength), + MinPrefixLength: utils.Ptr(testMinPrefixLength), + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasDeprecatedFlagsSet(tt.args.model); got != tt.want { + t.Errorf("hasDeprecatedFlagsSet() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/network-area/delete/delete.go b/internal/cmd/network-area/delete/delete.go index d617d8496..f9c20e3a9 100644 --- a/internal/cmd/network-area/delete/delete.go +++ b/internal/cmd/network-area/delete/delete.go @@ -4,6 +4,12 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -11,17 +17,17 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" - "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" - - "github.com/spf13/cobra" ) const ( areaIdArg = "AREA_ID" organizationIdFlag = "organization-id" + + deprecationMessage = "The regional network area configuration %q for the area %q still exists.\n" + + "The regional configuration of the network area was moved to the new command group `$ stackit network-area region`.\n" + + "The regional area will be automatically deleted. This behavior is deprecated and will be removed after April 2026.\n" + + "Use in the future the command `$ stackit network-area region delete` to delete the regional network area and afterwards delete the network-area with the command `$ stackit network-area delete`.\n" ) type inputModel struct { @@ -30,7 +36,7 @@ type inputModel struct { AreaId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", areaIdArg), Short: "Deletes a STACKIT Network Area (SNA)", @@ -47,30 +53,43 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, model.AreaId) if err != nil { - p.Debug(print.ErrorLevel, "get network area name: %v", err) - networkAreaLabel = model.AreaId - } else if networkAreaLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) networkAreaLabel = model.AreaId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete network area %q?", networkAreaLabel) - err = p.PromptForConfirmation(prompt) + prompt := fmt.Sprintf("Are you sure you want to delete network area %q?", networkAreaLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Check if the network area has a regional configuration + regionalArea, err := apiClient.GetNetworkAreaRegion(ctx, *model.OrganizationId, model.AreaId, model.Region).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get regional area: %v", err) + } + if regionalArea != nil { + params.Printer.Warn(deprecationMessage, model.Region, networkAreaLabel) + err = apiClient.DeleteNetworkAreaRegion(ctx, *model.OrganizationId, model.AreaId, model.Region).Execute() if err != nil { - return err + return fmt.Errorf("delete network area region: %w", err) + } + _, err := wait.DeleteNetworkAreaRegionWaitHandler(ctx, apiClient, *model.OrganizationId, model.AreaId, model.Region).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait delete network area region: %w", err) } } @@ -81,22 +100,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete network area: %w", err) } - // Wait for async operation, if async mode not enabled - if !model.Async { - s := spinner.New(p) - s.Start("Deleting network area") - _, err = wait.DeleteNetworkAreaWaitHandler(ctx, apiClient, *model.OrganizationId, model.AreaId).WaitWithContext(ctx) - if err != nil { - return fmt.Errorf("wait for network area deletion: %w", err) - } - s.Stop() - } - - operationState := "Deleted" - if model.Async { - operationState = "Triggered deletion of" - } - p.Info("%s STACKIT Network Area %q\n", operationState, networkAreaLabel) + params.Printer.Outputf("Deleted STACKIT Network Area %q\n", networkAreaLabel) return nil }, } @@ -122,15 +126,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu AreaId: areaId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/network-area/delete/delete_test.go b/internal/cmd/network-area/delete/delete_test.go index a3ff4f430..1ee322dc3 100644 --- a/internal/cmd/network-area/delete/delete_test.go +++ b/internal/cmd/network-area/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -135,54 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/network-area/describe/describe.go b/internal/cmd/network-area/describe/describe.go index 35be06f3c..0c467f2c7 100644 --- a/internal/cmd/network-area/describe/describe.go +++ b/internal/cmd/network-area/describe/describe.go @@ -2,11 +2,14 @@ package describe import ( "context" - "encoding/json" + "errors" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -16,7 +19,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -34,7 +36,7 @@ type inputModel struct { ShowAttachedProjects bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", areaIdArg), Short: "Shows details of a STACKIT Network Area", @@ -56,13 +58,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -78,12 +80,14 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.ShowAttachedProjects { projects, err = iaasUtils.ListAttachedProjects(ctx, apiClient, *model.OrganizationId, model.AreaId) - if err != nil { + if err != nil && errors.Is(err, iaasUtils.ErrItemsNil) { + projects = []string{} + } else if err != nil { return fmt.Errorf("get attached projects: %w", err) } } - return outputResult(p, model.OutputFormat, resp, projects) + return outputResult(params.Printer, model.OutputFormat, resp, projects) }, } configureFlags(cmd) @@ -110,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ShowAttachedProjects: flags.FlagToBoolValue(p, cmd, showAttachedProjectsFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -130,78 +126,13 @@ func outputResult(p *print.Printer, outputFormat string, networkArea *iaas.Netwo if networkArea == nil { return fmt.Errorf("network area is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(networkArea, "", " ") - if err != nil { - return fmt.Errorf("marshal network area: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(networkArea, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network area: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - var routes []string - var networkRanges []string - - if networkArea.Ipv4 != nil { - if networkArea.Ipv4.Routes != nil { - for _, route := range *networkArea.Ipv4.Routes { - routes = append(routes, fmt.Sprintf("next hop: %s\nprefix: %s", *route.Nexthop, *route.Prefix)) - } - } - - if networkArea.Ipv4.NetworkRanges != nil { - for _, networkRange := range *networkArea.Ipv4.NetworkRanges { - networkRanges = append(networkRanges, *networkRange.Prefix) - } - } - } + return p.OutputResult(outputFormat, networkArea, func() error { table := tables.NewTable() - table.AddRow("ID", utils.PtrString(networkArea.AreaId)) + table.AddRow("ID", utils.PtrString(networkArea.Id)) table.AddSeparator() table.AddRow("NAME", utils.PtrString(networkArea.Name)) table.AddSeparator() - table.AddRow("STATE", utils.PtrString(networkArea.State)) - table.AddSeparator() - if len(networkRanges) > 0 { - table.AddRow("NETWORK RANGES", strings.Join(networkRanges, ",")) - } - table.AddSeparator() - for i, route := range routes { - table.AddRow(fmt.Sprintf("STATIC ROUTE %d", i+1), route) - table.AddSeparator() - } - if networkArea.Ipv4 != nil { - if networkArea.Ipv4.TransferNetwork != nil { - table.AddRow("TRANSFER RANGE", *networkArea.Ipv4.TransferNetwork) - table.AddSeparator() - } - if networkArea.Ipv4.DefaultNameservers != nil && len(*networkArea.Ipv4.DefaultNameservers) > 0 { - table.AddRow("DNS NAME SERVERS", strings.Join(*networkArea.Ipv4.DefaultNameservers, ",")) - table.AddSeparator() - } - if networkArea.Ipv4.DefaultPrefixLen != nil { - table.AddRow("DEFAULT PREFIX LENGTH", *networkArea.Ipv4.DefaultPrefixLen) - table.AddSeparator() - } - if networkArea.Ipv4.MaxPrefixLen != nil { - table.AddRow("MAX PREFIX LENGTH", *networkArea.Ipv4.MaxPrefixLen) - table.AddSeparator() - } - if networkArea.Ipv4.MinPrefixLen != nil { - table.AddRow("MIN PREFIX LENGTH", *networkArea.Ipv4.MinPrefixLen) - table.AddSeparator() - } - } if networkArea.Labels != nil && len(*networkArea.Labels) > 0 { var labels []string for key, value := range *networkArea.Labels { @@ -217,11 +148,15 @@ func outputResult(p *print.Printer, outputFormat string, networkArea *iaas.Netwo table.AddRow("# ATTACHED PROJECTS", utils.PtrString(networkArea.ProjectCount)) table.AddSeparator() } + table.AddRow("CREATED AT", utils.PtrString(networkArea.CreatedAt)) + table.AddSeparator() + table.AddRow("UPDATED AT", utils.PtrString(networkArea.UpdatedAt)) + table.AddSeparator() err := table.Display(p) if err != nil { return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/network-area/describe/describe_test.go b/internal/cmd/network-area/describe/describe_test.go index 24c18cb06..d4cf7379c 100644 --- a/internal/cmd/network-area/describe/describe_test.go +++ b/internal/cmd/network-area/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -148,54 +151,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -253,7 +209,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.networkArea, tt.args.attachedProjects); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/list/list.go b/internal/cmd/network-area/list/list.go index 4e112ae53..cf8d9975d 100644 --- a/internal/cmd/network-area/list/list.go +++ b/internal/cmd/network-area/list/list.go @@ -2,11 +2,15 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + rmClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,11 +18,9 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" - rmClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" rmUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -34,7 +36,7 @@ type inputModel struct { LabelSelector *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all STACKIT Network Areas (SNA) of an organization", @@ -58,15 +60,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit network-area list --organization-id xxx --label-selector yyy", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -80,19 +82,19 @@ func NewCmd(p *print.Printer) *cobra.Command { if resp.Items == nil || len(*resp.Items) == 0 { var orgLabel string - rmApiClient, err := rmClient.ConfigureClient(p) + rmApiClient, err := rmClient.ConfigureClient(params.Printer, params.CliVersion) if err == nil { orgLabel, err = rmUtils.GetOrganizationName(ctx, rmApiClient, *model.OrganizationId) if err != nil { - p.Debug(print.ErrorLevel, "get organization name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err) orgLabel = *model.OrganizationId } else if orgLabel == "" { orgLabel = *model.OrganizationId } } else { - p.Debug(print.ErrorLevel, "configure resource manager client: %v", err) + params.Printer.Debug(print.ErrorLevel, "configure resource manager client: %v", err) } - p.Info("No STACKIT Network Areas found for organization %q\n", orgLabel) + params.Printer.Info("No STACKIT Network Areas found for organization %q\n", orgLabel) return nil } @@ -102,7 +104,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -118,7 +120,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) if limit != nil && *limit < 1 { @@ -135,15 +137,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -156,40 +150,14 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat string, networkAreas []iaas.NetworkArea) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(networkAreas, "", " ") - if err != nil { - return fmt.Errorf("marshal network area: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(networkAreas, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal area: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, networkAreas, func() error { table := tables.NewTable() - table.SetHeader("ID", "Name", "Status", "Network Ranges", "# Attached Projects") + table.SetHeader("ID", "Name", "# Attached Projects") for _, networkArea := range networkAreas { - networkRanges := "n/a" - if ipv4 := networkArea.Ipv4; ipv4 != nil { - if netRange := ipv4.NetworkRanges; netRange != nil { - networkRanges = fmt.Sprint(len(*netRange)) - } - } - table.AddRow( - utils.PtrString(networkArea.AreaId), + utils.PtrString(networkArea.Id), utils.PtrString(networkArea.Name), - utils.PtrString(networkArea.State), - networkRanges, utils.PtrString(networkArea.ProjectCount), ) table.AddSeparator() @@ -197,5 +165,5 @@ func outputResult(p *print.Printer, outputFormat string, networkAreas []iaas.Net p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/network-area/list/list_test.go b/internal/cmd/network-area/list/list_test.go index 4b71b3dda..2524bb8c8 100644 --- a/internal/cmd/network-area/list/list_test.go +++ b/internal/cmd/network-area/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -60,6 +63,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreasRequest)) iaas func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -129,46 +133,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -232,7 +197,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.networkAreas); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/network-range/create/create.go b/internal/cmd/network-area/network-range/create/create.go index 98fa25a60..0767cc9ea 100644 --- a/internal/cmd/network-area/network-range/create/create.go +++ b/internal/cmd/network-area/network-range/create/create.go @@ -2,10 +2,12 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -14,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -32,7 +33,7 @@ type inputModel struct { NetworkRange *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a network range in a STACKIT Network Area (SNA)", @@ -44,15 +45,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit network-area network-range create --network-area-id xxx --organization-id yyy --network-range "1.1.1.0/24"`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -60,16 +61,14 @@ func NewCmd(p *print.Printer) *cobra.Command { // Get network area label networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId) if err != nil { - p.Debug(print.ErrorLevel, "get network area name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) networkAreaLabel = *model.NetworkAreaId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a network range for STACKIT Network Area (SNA) %q?", networkAreaLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a network range for STACKIT Network Area (SNA) %q?", networkAreaLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -88,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return err } - return outputResult(p, model.OutputFormat, networkAreaLabel, networkRange) + return outputResult(params.Printer, model.OutputFormat, networkAreaLabel, networkRange) }, } configureFlags(cmd) @@ -104,7 +103,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) model := inputModel{ @@ -114,20 +113,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { NetworkRange: flags.FlagToStringPointer(p, cmd, networkRangeFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateNetworkAreaRangeRequest { - req := apiClient.CreateNetworkAreaRange(ctx, *model.OrganizationId, *model.NetworkAreaId) + req := apiClient.CreateNetworkAreaRange(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region) payload := iaas.CreateNetworkAreaRangePayload{ Ipv4: &[]iaas.NetworkRange{ { @@ -139,25 +130,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat, networkAreaLabel string, networkRange iaas.NetworkRange) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(networkRange, "", " ") - if err != nil { - return fmt.Errorf("marshal network range: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(networkRange, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network range: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - p.Outputf("Created network range for SNA %q.\nNetwork range ID: %s\n", networkAreaLabel, utils.PtrString(networkRange.NetworkRangeId)) + return p.OutputResult(outputFormat, networkRange, func() error { + p.Outputf("Created network range for SNA %q.\nNetwork range ID: %s\n", networkAreaLabel, utils.PtrString(networkRange.Id)) return nil - } + }) } diff --git a/internal/cmd/network-area/network-range/create/create_test.go b/internal/cmd/network-area/network-range/create/create_test.go index f3eb51bf1..913e66b60 100644 --- a/internal/cmd/network-area/network-range/create/create_test.go +++ b/internal/cmd/network-area/network-range/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,6 +17,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -24,6 +31,8 @@ var testNetworkAreaId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, networkAreaIdFlag: testNetworkAreaId, networkRangeFlag: "1.1.1.0/24", @@ -38,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, OrganizationId: utils.Ptr(testOrgId), NetworkAreaId: utils.Ptr(testNetworkAreaId), @@ -50,7 +60,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkAreaRangeRequest)) iaas.ApiCreateNetworkAreaRangeRequest { - request := testClient.CreateNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId) + request := testClient.CreateNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId, testRegion) request = request.CreateNetworkAreaRangePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -75,6 +85,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaRangePayload)) i func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -144,46 +155,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -241,7 +213,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.networkAreaLabel, tt.args.networkRange); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/network-range/delete/delete.go b/internal/cmd/network-area/network-range/delete/delete.go index 4b4ab2eda..eb0daa88d 100644 --- a/internal/cmd/network-area/network-range/delete/delete.go +++ b/internal/cmd/network-area/network-range/delete/delete.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -12,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -31,7 +34,7 @@ type inputModel struct { NetworkRangeId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", networkRangeIdArg), Short: "Deletes a network range in a STACKIT Network Area (SNA)", @@ -45,38 +48,34 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId) if err != nil { - p.Debug(print.ErrorLevel, "get network area name: %v", err) - networkAreaLabel = *model.NetworkAreaId - } else if networkAreaLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) networkAreaLabel = *model.NetworkAreaId } - networkRangeLabel, err := iaasUtils.GetNetworkRangePrefix(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId, model.NetworkRangeId) + networkRangeLabel, err := iaasUtils.GetNetworkRangePrefix(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId, model.Region, model.NetworkRangeId) if err != nil { - p.Debug(print.ErrorLevel, "get network range prefix: %v", err) + params.Printer.Debug(print.ErrorLevel, "get network range prefix: %v", err) networkRangeLabel = model.NetworkRangeId } else if networkRangeLabel == "" { networkRangeLabel = model.NetworkRangeId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete network range %q on STACKIT Network Area (SNA) %q?", networkRangeLabel, networkAreaLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete network range %q on STACKIT Network Area (SNA) %q?", networkRangeLabel, networkAreaLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -86,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete network range: %w", err) } - p.Info("Deleted network range %q on SNA %q\n", networkRangeLabel, networkAreaLabel) + params.Printer.Info("Deleted network range %q on SNA %q\n", networkRangeLabel, networkAreaLabel) return nil }, @@ -114,19 +113,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu NetworkRangeId: networkRangeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteNetworkAreaRangeRequest { - req := apiClient.DeleteNetworkAreaRange(ctx, *model.OrganizationId, *model.NetworkAreaId, model.NetworkRangeId) + req := apiClient.DeleteNetworkAreaRange(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region, model.NetworkRangeId) return req } diff --git a/internal/cmd/network-area/network-range/delete/delete_test.go b/internal/cmd/network-area/network-range/delete/delete_test.go index 5955ea316..a087681a8 100644 --- a/internal/cmd/network-area/network-range/delete/delete_test.go +++ b/internal/cmd/network-area/network-range/delete/delete_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,6 +16,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,6 +41,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, networkAreaIdFlag: testNetworkAreaId, } @@ -48,6 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, OrganizationId: utils.Ptr(testOrgId), NetworkAreaId: utils.Ptr(testNetworkAreaId), @@ -60,7 +69,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteNetworkAreaRangeRequest)) iaas.ApiDeleteNetworkAreaRangeRequest { - request := testClient.DeleteNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId, testNetworkRangeId) + request := testClient.DeleteNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId, testRegion, testNetworkRangeId) for _, mod := range mods { mod(&request) } @@ -156,7 +165,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/network-area/network-range/describe/describe.go b/internal/cmd/network-area/network-range/describe/describe.go index 9f35dc145..6ba53cb83 100644 --- a/internal/cmd/network-area/network-range/describe/describe.go +++ b/internal/cmd/network-area/network-range/describe/describe.go @@ -2,10 +2,12 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -14,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -33,7 +34,7 @@ type inputModel struct { NetworkRangeId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", networkRangeIdArg), Short: "Shows details of a network range in a STACKIT Network Area (SNA)", @@ -47,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe network range: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -91,20 +92,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu NetworkRangeId: networkRangeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetNetworkAreaRangeRequest { - req := apiClient.GetNetworkAreaRange(ctx, *model.OrganizationId, *model.NetworkAreaId, model.NetworkRangeId) + req := apiClient.GetNetworkAreaRange(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region, model.NetworkRangeId) return req } @@ -112,26 +105,10 @@ func outputResult(p *print.Printer, outputFormat string, networkRange *iaas.Netw if networkRange == nil { return fmt.Errorf("network range is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(networkRange, "", " ") - if err != nil { - return fmt.Errorf("marshal network range: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(networkRange, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network range: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, networkRange, func() error { table := tables.NewTable() - table.AddRow("ID", utils.PtrString(networkRange.NetworkRangeId)) + table.AddRow("ID", utils.PtrString(networkRange.Id)) table.AddSeparator() table.AddRow("Network range", utils.PtrString(networkRange.Prefix)) @@ -140,5 +117,5 @@ func outputResult(p *print.Printer, outputFormat string, networkRange *iaas.Netw return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/network-area/network-range/describe/describe_test.go b/internal/cmd/network-area/network-range/describe/describe_test.go index c03229238..ede21b094 100644 --- a/internal/cmd/network-area/network-range/describe/describe_test.go +++ b/internal/cmd/network-area/network-range/describe/describe_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,6 +16,11 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + projectIdFlag = globalflags.ProjectIdFlag + testRegion = "eu01" +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,6 +42,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, networkAreaIdFlag: testNetworkAreaId, } @@ -47,6 +56,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, OrganizationId: utils.Ptr(testOrgId), @@ -60,7 +70,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetNetworkAreaRangeRequest)) iaas.ApiGetNetworkAreaRangeRequest { - request := testClient.GetNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId, testNetworkRangeId) + request := testClient.GetNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId, testRegion, testNetworkRangeId) for _, mod := range mods { mod(&request) } @@ -156,7 +166,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -259,7 +269,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.networkRange); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/network-range/list/list.go b/internal/cmd/network-area/network-range/list/list.go index b9b11d0e2..4ad161d30 100644 --- a/internal/cmd/network-area/network-range/list/list.go +++ b/internal/cmd/network-area/network-range/list/list.go @@ -2,12 +2,14 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -16,7 +18,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -34,7 +35,7 @@ type inputModel struct { NetworkAreaId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all network ranges in a STACKIT Network Area (SNA)", @@ -54,15 +55,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit network-area network-range list --network-area-id xxx --organization-id yyy --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -78,12 +79,10 @@ func NewCmd(p *print.Printer) *cobra.Command { var networkAreaLabel string networkAreaLabel, err = iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId) if err != nil { - p.Debug(print.ErrorLevel, "get organization name: %v", err) - networkAreaLabel = *model.NetworkAreaId - } else if networkAreaLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err) networkAreaLabel = *model.NetworkAreaId } - p.Info("No network ranges found for SNA %q\n", networkAreaLabel) + params.Printer.Info("No network ranges found for SNA %q\n", networkAreaLabel) return nil } @@ -93,7 +92,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -109,11 +108,11 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) if limit != nil && *limit < 1 { - return nil, &errors.FlagValidationError{ + return nil, &cliErr.FlagValidationError{ Flag: limitFlag, Details: "must be greater than 0", } @@ -126,49 +125,24 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListNetworkAreaRangesRequest { - return apiClient.ListNetworkAreaRanges(ctx, *model.OrganizationId, *model.NetworkAreaId) + return apiClient.ListNetworkAreaRanges(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region) } func outputResult(p *print.Printer, outputFormat string, networkRanges []iaas.NetworkRange) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(networkRanges, "", " ") - if err != nil { - return fmt.Errorf("marshal network ranges: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(networkRanges, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network ranges: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, networkRanges, func() error { table := tables.NewTable() table.SetHeader("ID", "Network Range") for _, networkRange := range networkRanges { - table.AddRow(utils.PtrString(networkRange.NetworkRangeId), utils.PtrString(networkRange.Prefix)) + table.AddRow(utils.PtrString(networkRange.Id), utils.PtrString(networkRange.Prefix)) } p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/network-area/network-range/list/list_test.go b/internal/cmd/network-area/network-range/list/list_test.go index 26efc608c..80ab8a7c4 100644 --- a/internal/cmd/network-area/network-range/list/list_test.go +++ b/internal/cmd/network-area/network-range/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,6 +17,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -23,6 +30,8 @@ var testNetworkAreaId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrganizationId, networkAreaIdFlag: testNetworkAreaId, limitFlag: "10", @@ -36,6 +45,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, OrganizationId: &testOrganizationId, @@ -49,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreaRangesRequest)) iaas.ApiListNetworkAreaRangesRequest { - request := testClient.ListNetworkAreaRanges(testCtx, testOrganizationId, testNetworkAreaId) + request := testClient.ListNetworkAreaRanges(testCtx, testOrganizationId, testNetworkAreaId, testRegion) for _, mod := range mods { mod(&request) } @@ -59,6 +69,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreaRangesRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -139,46 +150,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -242,7 +214,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.networkRanges); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/network-range/network_range.go b/internal/cmd/network-area/network-range/network_range.go index 71c849f17..adf53b654 100644 --- a/internal/cmd/network-area/network-range/network_range.go +++ b/internal/cmd/network-area/network-range/network_range.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/network-range/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/network-range/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "network-range", Aliases: []string{"range"}, @@ -21,13 +21,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/network-area/network_area.go b/internal/cmd/network-area/network_area.go index ef12c22a8..a47e9585d 100644 --- a/internal/cmd/network-area/network_area.go +++ b/internal/cmd/network-area/network_area.go @@ -6,16 +6,18 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/list" networkrange "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/network-range" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/region" "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/route" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable" "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "network-area", Short: "Provides functionality for STACKIT Network Area (SNA)", @@ -23,16 +25,18 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(networkrange.NewCmd(p)) - cmd.AddCommand(route.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(networkrange.NewCmd(params)) + cmd.AddCommand(routingtable.NewCmd(params)) + cmd.AddCommand(region.NewCmd(params)) + cmd.AddCommand(route.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/network-area/region/create/create.go b/internal/cmd/network-area/region/create/create.go new file mode 100644 index 000000000..791c8fabe --- /dev/null +++ b/internal/cmd/network-area/region/create/create.go @@ -0,0 +1,199 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + ipv4DefaultNameservers = "ipv4-default-nameservers" + ipv4DefaultPrefixLengthFlag = "ipv4-default-prefix-length" + ipv4MaxPrefixLengthFlag = "ipv4-max-prefix-length" + ipv4MinPrefixLengthFlag = "ipv4-min-prefix-length" + ipv4NetworkRangesFlag = "ipv4-network-ranges" + ipv4TransferNetworkFlag = "ipv4-transfer-network" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + NetworkAreaId string + + IPv4DefaultNameservers *[]string + IPv4DefaultPrefixLength *int64 + IPv4MaxPrefixLength *int64 + IPv4MinPrefixLength *int64 + IPv4NetworkRanges []string + IPv4TransferNetwork string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a new regional configuration for a STACKIT Network Area (SNA)", + Long: "Creates a new regional configuration for a STACKIT Network Area (SNA).", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a new regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24" and ipv4 transfer network "192.168.1.0/24"`, + `$ stackit network-area region create --network-area-id xxx --region eu02 --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24`, + ), + examples.NewExample( + `Create a new regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", using the set region config`, + `$ stackit config set --region eu02`, + `$ stackit network-area region create --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24`, + ), + examples.NewExample( + `Create a new regional configuration for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24", ipv4 transfer network "192.168.1.0/24", default prefix length "24", max prefix length "25" and min prefix length "20"`, + `$ stackit network-area region create --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 --region "eu02" --ipv4-default-prefix-length 24 --ipv4-max-prefix-length 25 --ipv4-min-prefix-length 20`, + ), + examples.NewExample( + `Create a new regional configuration for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24", ipv4 transfer network "192.168.1.0/24", default prefix length "24", max prefix length "25" and min prefix length "20"`, + `$ stackit network-area region create --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 --region "eu02" --ipv4-default-prefix-length 24 --ipv4-max-prefix-length 25 --ipv4-min-prefix-length 20`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get network area label + networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) + networkAreaLabel = model.NetworkAreaId + } + + prompt := fmt.Sprintf("Are you sure you want to create the regional configuration %q for STACKIT Network Area (SNA) %q?", model.Region, networkAreaLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create network area region: %w", err) + } + + if resp == nil || resp.Ipv4 == nil { + return fmt.Errorf("empty response from API") + } + + if !model.Async { + err := spinner.Run(params.Printer, "Create network area region", func() error { + _, err = wait.CreateNetworkAreaRegionWaitHandler(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for network area region creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, model.Region, networkAreaLabel, *resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.CIDRSliceFlag(), ipv4NetworkRangesFlag, "Network range to create in CIDR notation") + cmd.Flags().Var(flags.CIDRFlag(), ipv4TransferNetworkFlag, "Transfer network in CIDR notation") + cmd.Flags().StringSlice(ipv4DefaultNameservers, nil, "List of default DNS name server IPs") + cmd.Flags().Int64(ipv4DefaultPrefixLengthFlag, 0, "The default prefix length for networks in the network area") + cmd.Flags().Int64(ipv4MaxPrefixLengthFlag, 0, "The maximum prefix length for networks in the network area") + cmd.Flags().Int64(ipv4MinPrefixLengthFlag, 0, "The minimum prefix length for networks in the network area") + + err := flags.MarkFlagsRequired(cmd, networkAreaIdFlag, organizationIdFlag, ipv4NetworkRangesFlag, ipv4TransferNetworkFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + IPv4DefaultNameservers: flags.FlagToStringSlicePointer(p, cmd, ipv4DefaultNameservers), + IPv4DefaultPrefixLength: flags.FlagToInt64Pointer(p, cmd, ipv4DefaultPrefixLengthFlag), + IPv4MaxPrefixLength: flags.FlagToInt64Pointer(p, cmd, ipv4MaxPrefixLengthFlag), + IPv4MinPrefixLength: flags.FlagToInt64Pointer(p, cmd, ipv4MinPrefixLengthFlag), + IPv4NetworkRanges: flags.FlagToStringSliceValue(p, cmd, ipv4NetworkRangesFlag), + IPv4TransferNetwork: flags.FlagToStringValue(p, cmd, ipv4TransferNetworkFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateNetworkAreaRegionRequest { + req := apiClient.CreateNetworkAreaRegion(ctx, model.OrganizationId, model.NetworkAreaId, model.Region) + + var networkRange []iaas.NetworkRange + if len(model.IPv4NetworkRanges) > 0 { + networkRange = make([]iaas.NetworkRange, len(model.IPv4NetworkRanges)) + for i := range model.IPv4NetworkRanges { + networkRange[i] = iaas.NetworkRange{ + Prefix: utils.Ptr(model.IPv4NetworkRanges[i]), + } + } + } + + payload := iaas.CreateNetworkAreaRegionPayload{ + Ipv4: &iaas.RegionalAreaIPv4{ + DefaultNameservers: model.IPv4DefaultNameservers, + DefaultPrefixLen: model.IPv4DefaultPrefixLength, + MaxPrefixLen: model.IPv4MaxPrefixLength, + MinPrefixLen: model.IPv4MinPrefixLength, + NetworkRanges: utils.Ptr(networkRange), + TransferNetwork: utils.Ptr(model.IPv4TransferNetwork), + }, + } + return req.CreateNetworkAreaRegionPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, async bool, region, networkAreaLabel string, regionalArea iaas.RegionalArea) error { + return p.OutputResult(outputFormat, regionalArea, func() error { + operationState := "Created" + if async { + operationState = "Triggered creation of" + } + p.Outputf("%s region configuration for SNA %q.\nRegion: %s\n", operationState, networkAreaLabel, region) + return nil + }) +} diff --git a/internal/cmd/network-area/region/create/create_test.go b/internal/cmd/network-area/region/create/create_test.go new file mode 100644 index 000000000..d04b8dc23 --- /dev/null +++ b/internal/cmd/network-area/region/create/create_test.go @@ -0,0 +1,310 @@ +package create + +import ( + "context" + "strconv" + "strings" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testRegion = "eu01" + testDefaultPrefixLength int64 = 25 + testMaxPrefixLength int64 = 29 + testMinPrefixLength int64 = 24 + testTransferNetwork = "192.168.2.0/24" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +var ( + testAreaId = uuid.NewString() + testOrgId = uuid.NewString() + testDefaultNameservers = []string{"8.8.8.8", "8.8.4.4"} + testNetworkRanges = []string{"192.168.0.0/24", "10.0.0.0/24"} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + + networkAreaIdFlag: testAreaId, + organizationIdFlag: testOrgId, + ipv4DefaultNameservers: strings.Join(testDefaultNameservers, ","), + ipv4DefaultPrefixLengthFlag: strconv.FormatInt(testDefaultPrefixLength, 10), + ipv4MaxPrefixLengthFlag: strconv.FormatInt(testMaxPrefixLength, 10), + ipv4MinPrefixLengthFlag: strconv.FormatInt(testMinPrefixLength, 10), + ipv4NetworkRangesFlag: strings.Join(testNetworkRanges, ","), + ipv4TransferNetworkFlag: testTransferNetwork, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrgId, + NetworkAreaId: testAreaId, + IPv4DefaultNameservers: utils.Ptr(testDefaultNameservers), + IPv4DefaultPrefixLength: utils.Ptr(testDefaultPrefixLength), + IPv4MaxPrefixLength: utils.Ptr(testMaxPrefixLength), + IPv4MinPrefixLength: utils.Ptr(testMinPrefixLength), + IPv4NetworkRanges: testNetworkRanges, + IPv4TransferNetwork: testTransferNetwork, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkAreaRegionRequest)) iaas.ApiCreateNetworkAreaRegionRequest { + request := testClient.CreateNetworkAreaRegion(testCtx, testOrgId, testAreaId, testRegion) + request = request.CreateNetworkAreaRegionPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaRegionPayload)) iaas.CreateNetworkAreaRegionPayload { + var networkRange []iaas.NetworkRange + if len(testNetworkRanges) > 0 { + networkRange = make([]iaas.NetworkRange, len(testNetworkRanges)) + for i := range testNetworkRanges { + networkRange[i] = iaas.NetworkRange{ + Prefix: utils.Ptr(testNetworkRanges[i]), + } + } + } + + payload := iaas.CreateNetworkAreaRegionPayload{ + Ipv4: &iaas.RegionalAreaIPv4{ + DefaultNameservers: utils.Ptr(testDefaultNameservers), + DefaultPrefixLen: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLen: utils.Ptr(testMaxPrefixLength), + MinPrefixLen: utils.Ptr(testMinPrefixLength), + NetworkRanges: utils.Ptr(networkRange), + TransferNetwork: utils.Ptr(testTransferNetwork), + }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []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: "area id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "area id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "area id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "org id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "org id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "org id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "network range missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ipv4NetworkRangesFlag) + }), + isValid: false, + }, + { + description: "multiple network ranges", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4NetworkRangesFlag] = "192.168.2.0/24,10.0.0.0/24" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.IPv4NetworkRanges = []string{"192.168.2.0/24", "10.0.0.0/24"} + }), + isValid: true, + }, + { + description: "network range invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4NetworkRangesFlag] = "invalid-cidr" + }), + isValid: false, + }, + { + description: "transfer network missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ipv4TransferNetworkFlag) + }), + isValid: false, + }, + { + description: "transfer network invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4TransferNetworkFlag] = "" + }), + isValid: false, + }, + { + description: "transfer network invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4TransferNetworkFlag] = "invalid-cidr" + }), + isValid: false, + }, + { + description: "region empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.RegionFlag] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiCreateNetworkAreaRegionRequest + }{ + { + 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) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + async bool + region string + networkAreaLabel string + regionalArea iaas.RegionalArea + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty regional area", + args: args{ + regionalArea: iaas.RegionalArea{}, + }, + wantErr: false, + }, + { + name: "output json", + args: args{ + outputFormat: print.JSONOutputFormat, + regionalArea: iaas.RegionalArea{}, + }, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.region, tt.args.networkAreaLabel, tt.args.regionalArea); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/region/delete/delete.go b/internal/cmd/network-area/region/delete/delete.go new file mode 100644 index 000000000..d016aff73 --- /dev/null +++ b/internal/cmd/network-area/region/delete/delete.go @@ -0,0 +1,129 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + NetworkAreaId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Deletes a regional configuration for a STACKIT Network Area (SNA)", + Long: "Deletes a regional configuration for a STACKIT Network Area (SNA).", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Delete a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`, + `$ stackit network-area region delete --network-area-id xxx --region eu02 --organization-id yyy`, + ), + examples.NewExample( + `Delete a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", using the set region config`, + `$ stackit config set --region eu02`, + `$ stackit network-area region delete --network-area-id xxx --organization-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get network area label + networkAreaName, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) + networkAreaName = model.NetworkAreaId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the regional configuration %q for STACKIT Network Area (SNA) %q?", model.Region, networkAreaName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete network area region: %w", err) + } + + if !model.Async { + err := spinner.Run(params.Printer, "Delete network area region", func() error { + _, err = wait.DeleteNetworkAreaRegionWaitHandler(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for network area region deletion: %w", err) + } + } + + params.Printer.Outputf("Delete regional network area %q for %q\n", model.Region, networkAreaName) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, networkAreaIdFlag, organizationIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteNetworkAreaRegionRequest { + return apiClient.DeleteNetworkAreaRegion(ctx, model.OrganizationId, model.NetworkAreaId, model.Region) +} diff --git a/internal/cmd/network-area/region/delete/delete_test.go b/internal/cmd/network-area/region/delete/delete_test.go new file mode 100644 index 000000000..5a47b2b49 --- /dev/null +++ b/internal/cmd/network-area/region/delete/delete_test.go @@ -0,0 +1,169 @@ +package delete + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +var ( + testAreaId = uuid.NewString() + testOrgId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + + networkAreaIdFlag: testAreaId, + organizationIdFlag: testOrgId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrgId, + NetworkAreaId: testAreaId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteNetworkAreaRegionRequest)) iaas.ApiDeleteNetworkAreaRegionRequest { + request := testClient.DeleteNetworkAreaRegion(testCtx, testOrgId, testAreaId, testRegion) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "area id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "area id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "area id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "org id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "org id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "org id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "region empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.RegionFlag] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiDeleteNetworkAreaRegionRequest + }{ + { + 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/network-area/region/describe/describe.go b/internal/cmd/network-area/region/describe/describe.go new file mode 100644 index 000000000..4694d1db5 --- /dev/null +++ b/internal/cmd/network-area/region/describe/describe.go @@ -0,0 +1,171 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + NetworkAreaId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describes a regional configuration for a STACKIT Network Area (SNA)", + Long: "Describes a regional configuration for a STACKIT Network Area (SNA).", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Describe a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`, + `$ stackit network-area region describe --network-area-id xxx --region eu02 --organization-id yyy`, + ), + examples.NewExample( + `Describe a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", using the set region config`, + `$ stackit config set --region eu02`, + `$ stackit network-area region describe --network-area-id xxx --organization-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get network area label + networkAreaName, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) + // Set explicit the networkAreaName to empty string and not to the ID, because this is used for the table output + networkAreaName = "" + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("describe network area region: %w", err) + } + + if resp == nil || resp.Ipv4 == nil { + return fmt.Errorf("empty response from API") + } + + return outputResult(params.Printer, model.OutputFormat, model.Region, model.NetworkAreaId, networkAreaName, *resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, networkAreaIdFlag, organizationIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetNetworkAreaRegionRequest { + return apiClient.GetNetworkAreaRegion(ctx, model.OrganizationId, model.NetworkAreaId, model.Region) +} + +func outputResult(p *print.Printer, outputFormat, region, areaId, areaName string, regionalArea iaas.RegionalArea) error { + return p.OutputResult(outputFormat, regionalArea, func() error { + table := tables.NewTable() + table.AddRow("ID", areaId) + table.AddSeparator() + if areaName != "" { + table.AddRow("NAME", areaName) + table.AddSeparator() + } + table.AddRow("REGION", region) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(regionalArea.Status)) + table.AddSeparator() + if ipv4 := regionalArea.Ipv4; ipv4 != nil { + if ipv4.NetworkRanges != nil { + var networkRanges []string + for _, networkRange := range *ipv4.NetworkRanges { + if networkRange.Prefix != nil { + networkRanges = append(networkRanges, *networkRange.Prefix) + } + } + table.AddRow("NETWORK RANGES", strings.Join(networkRanges, ",")) + table.AddSeparator() + } + if transferNetwork := ipv4.TransferNetwork; transferNetwork != nil { + table.AddRow("TRANSFER RANGE", utils.PtrString(transferNetwork)) + table.AddSeparator() + } + if defaultNameserver := ipv4.DefaultNameservers; defaultNameserver != nil && len(*defaultNameserver) > 0 { + table.AddRow("DNS NAME SERVERS", strings.Join(*defaultNameserver, ",")) + table.AddSeparator() + } + if defaultPrefixLength := ipv4.DefaultPrefixLen; defaultPrefixLength != nil { + table.AddRow("DEFAULT PREFIX LENGTH", utils.PtrString(defaultPrefixLength)) + table.AddSeparator() + } + if maxPrefixLength := ipv4.MaxPrefixLen; maxPrefixLength != nil { + table.AddRow("MAX PREFIX LENGTH", utils.PtrString(maxPrefixLength)) + table.AddSeparator() + } + if minPrefixLen := ipv4.MinPrefixLen; minPrefixLen != nil { + table.AddRow("MIN PREFIX LENGTH", utils.PtrString(minPrefixLen)) + table.AddSeparator() + } + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/network-area/region/describe/describe_test.go b/internal/cmd/network-area/region/describe/describe_test.go new file mode 100644 index 000000000..18a040739 --- /dev/null +++ b/internal/cmd/network-area/region/describe/describe_test.go @@ -0,0 +1,216 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +var ( + testAreaId = uuid.NewString() + testOrgId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + + networkAreaIdFlag: testAreaId, + organizationIdFlag: testOrgId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrgId, + NetworkAreaId: testAreaId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetNetworkAreaRegionRequest)) iaas.ApiGetNetworkAreaRegionRequest { + request := testClient.GetNetworkAreaRegion(testCtx, testOrgId, testAreaId, testRegion) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "org id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "org id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "org id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "area id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "area id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "area id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "region empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.RegionFlag] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiGetNetworkAreaRegionRequest + }{ + { + 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) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + areaId string + region string + networkAreaLabel string + regionalArea iaas.RegionalArea + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty regional area", + args: args{ + regionalArea: iaas.RegionalArea{}, + }, + wantErr: false, + }, + { + name: "output json", + args: args{ + outputFormat: print.JSONOutputFormat, + regionalArea: iaas.RegionalArea{}, + }, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.region, tt.args.areaId, tt.args.networkAreaLabel, tt.args.regionalArea); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/region/list/list.go b/internal/cmd/network-area/region/list/list.go new file mode 100644 index 000000000..4988c87cc --- /dev/null +++ b/internal/cmd/network-area/region/list/list.go @@ -0,0 +1,155 @@ +package list + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + NetworkAreaId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all configured regions for a STACKIT Network Area (SNA)", + Long: "Lists all configured regions for a STACKIT Network Area (SNA).", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all configured region for a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`, + `$ stackit network-area region list --network-area-id xxx --organization-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get network area label + networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) + networkAreaLabel = model.NetworkAreaId + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list network area region: %w", err) + } + + if resp == nil { + return fmt.Errorf("empty response from API") + } + + return outputResult(params.Printer, model.OutputFormat, networkAreaLabel, *resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, networkAreaIdFlag, organizationIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListNetworkAreaRegionsRequest { + return apiClient.ListNetworkAreaRegions(ctx, model.OrganizationId, model.NetworkAreaId) +} + +func outputResult(p *print.Printer, outputFormat, areaLabel string, regionalArea iaas.RegionalAreaListResponse) error { + return p.OutputResult(outputFormat, regionalArea, func() error { + if regionalArea.Regions == nil || len(*regionalArea.Regions) == 0 { + p.Outputf("No regions found for network area %q\n", areaLabel) + return nil + } + + table := tables.NewTable() + table.SetHeader("REGION", "STATUS", "DNS NAME SERVERS", "NETWORK RANGES", "TRANSFER NETWORK") + for region, regionConfig := range *regionalArea.Regions { + var dnsNames string + var networkRanges []string + var transferNetwork string + + if ipv4 := regionConfig.Ipv4; ipv4 != nil { + // Set dnsNames + dnsNames = utils.JoinStringPtr(ipv4.DefaultNameservers, ",") + + // Set networkRanges + if ipv4.NetworkRanges != nil && len(*ipv4.NetworkRanges) > 0 { + for _, networkRange := range *ipv4.NetworkRanges { + if networkRange.Prefix != nil { + networkRanges = append(networkRanges, *networkRange.Prefix) + } + } + } + + // Set transferNetwork + transferNetwork = utils.PtrString(ipv4.TransferNetwork) + } + + table.AddRow( + region, + utils.PtrString(regionConfig.Status), + dnsNames, + strings.Join(networkRanges, ","), + transferNetwork, + ) + } + + if err := table.Display(p); err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/network-area/region/list/list_test.go b/internal/cmd/network-area/region/list/list_test.go new file mode 100644 index 000000000..34cd219e0 --- /dev/null +++ b/internal/cmd/network-area/region/list/list_test.go @@ -0,0 +1,223 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +var ( + testAreaId = uuid.NewString() + testOrgId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + networkAreaIdFlag: testAreaId, + organizationIdFlag: testOrgId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrgId, + NetworkAreaId: testAreaId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreaRegionsRequest)) iaas.ApiListNetworkAreaRegionsRequest { + request := testClient.ListNetworkAreaRegions(testCtx, testOrgId, testAreaId) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "org id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "org id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "org id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "area id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "area id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "area id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListNetworkAreaRegionsRequest + }{ + { + 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) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + networkAreaLabel string + regionalArea iaas.RegionalAreaListResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty response", + args: args{ + regionalArea: iaas.RegionalAreaListResponse{}, + }, + wantErr: false, + }, + { + name: "set nil for regions map in response", + args: args{ + regionalArea: iaas.RegionalAreaListResponse{ + Regions: nil, + }, + }, + wantErr: false, + }, + { + name: "set empty map for regions map in response", + args: args{ + regionalArea: iaas.RegionalAreaListResponse{ + Regions: utils.Ptr(map[string]iaas.RegionalArea{}), + }, + }, + wantErr: false, + }, + { + name: "set empty region in response", + args: args{ + regionalArea: iaas.RegionalAreaListResponse{ + Regions: utils.Ptr(map[string]iaas.RegionalArea{ + "eu01": {}, + }), + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.networkAreaLabel, tt.args.regionalArea); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/region/region.go b/internal/cmd/network-area/region/region.go new file mode 100644 index 000000000..d21eaa106 --- /dev/null +++ b/internal/cmd/network-area/region/region.go @@ -0,0 +1,34 @@ +package region + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/region/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/region/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/region/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/region/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/region/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "region", + Short: "Provides functionality for regional configuration of STACKIT Network Area (SNA)", + Long: "Provides functionality for regional configuration of STACKIT Network Area (SNA).", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/network-area/region/update/update.go b/internal/cmd/network-area/region/update/update.go new file mode 100644 index 000000000..151c83a50 --- /dev/null +++ b/internal/cmd/network-area/region/update/update.go @@ -0,0 +1,165 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + ipv4DefaultNameservers = "ipv4-default-nameservers" + ipv4DefaultPrefixLengthFlag = "ipv4-default-prefix-length" + ipv4MaxPrefixLengthFlag = "ipv4-max-prefix-length" + ipv4MinPrefixLengthFlag = "ipv4-min-prefix-length" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + NetworkAreaId string + + IPv4DefaultNameservers *[]string + IPv4DefaultPrefixLength *int64 + IPv4MaxPrefixLength *int64 + IPv4MinPrefixLength *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Updates a existing regional configuration for a STACKIT Network Area (SNA)", + Long: "Updates a existing regional configuration for a STACKIT Network Area (SNA).", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Update a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy" with new ipv4-default-nameservers "8.8.8.8"`, + `$ stackit network-area region update --network-area-id xxx --region eu02 --organization-id yyy --ipv4-default-nameservers 8.8.8.8`, + ), + examples.NewExample( + `Update a regional configuration "eu02" for a STACKIT Network Area with ID "xxx" in organization with ID "yyy" with new ipv4-default-nameservers "8.8.8.8", using the set region config`, + `$ stackit config set --region eu02`, + `$ stackit network-area region update --network-area-id xxx --organization-id yyy --ipv4-default-nameservers 8.8.8.8`, + ), + examples.NewExample( + `Update a new regional configuration for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24", ipv4 transfer network "192.168.1.0/24", default prefix length "24", max prefix length "25" and min prefix length "20"`, + `$ stackit network-area region update --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 --region "eu02" --ipv4-default-prefix-length 24 --ipv4-max-prefix-length 25 --ipv4-min-prefix-length 20`, + ), + examples.NewExample( + `Update a new regional configuration for a STACKIT Network Area with ID "xxx" in organization with ID "yyy", ipv4 network range "192.168.0.0/24", ipv4 transfer network "192.168.1.0/24", default prefix length "24", max prefix length "25" and min prefix length "20"`, + `$ stackit network-area region update --network-area-id xxx --organization-id yyy --ipv4-network-ranges 192.168.0.0/24 --ipv4-transfer-network 192.168.1.0/24 --region "eu02" --ipv4-default-prefix-length 24 --ipv4-max-prefix-length 25 --ipv4-min-prefix-length 20`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get network area label + networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) + networkAreaLabel = model.NetworkAreaId + } + + prompt := fmt.Sprintf("Are you sure you want to update the regional configuration %q for STACKIT Network Area (SNA) %q?", model.Region, networkAreaLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update network area region: %w", err) + } + + if resp == nil || resp.Ipv4 == nil { + return fmt.Errorf("empty response from API") + } + + return outputResult(params.Printer, model.OutputFormat, model.Region, networkAreaLabel, *resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().StringSlice(ipv4DefaultNameservers, nil, "List of default DNS name server IPs") + cmd.Flags().Int64(ipv4DefaultPrefixLengthFlag, 0, "The default prefix length for networks in the network area") + cmd.Flags().Int64(ipv4MaxPrefixLengthFlag, 0, "The maximum prefix length for networks in the network area") + cmd.Flags().Int64(ipv4MinPrefixLengthFlag, 0, "The minimum prefix length for networks in the network area") + + // At least one of the flags is required, otherwise there is nothing to update + cmd.MarkFlagsOneRequired(ipv4DefaultNameservers, ipv4MaxPrefixLengthFlag, ipv4MinPrefixLengthFlag, ipv4DefaultPrefixLengthFlag) + + err := flags.MarkFlagsRequired(cmd, networkAreaIdFlag, organizationIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.Region == "" { + return nil, &errors.RegionError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + IPv4DefaultNameservers: flags.FlagToStringSlicePointer(p, cmd, ipv4DefaultNameservers), + IPv4DefaultPrefixLength: flags.FlagToInt64Pointer(p, cmd, ipv4DefaultPrefixLengthFlag), + IPv4MaxPrefixLength: flags.FlagToInt64Pointer(p, cmd, ipv4MaxPrefixLengthFlag), + IPv4MinPrefixLength: flags.FlagToInt64Pointer(p, cmd, ipv4MinPrefixLengthFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateNetworkAreaRegionRequest { + req := apiClient.UpdateNetworkAreaRegion(ctx, model.OrganizationId, model.NetworkAreaId, model.Region) + + payload := iaas.UpdateNetworkAreaRegionPayload{ + Ipv4: &iaas.UpdateRegionalAreaIPv4{ + DefaultNameservers: model.IPv4DefaultNameservers, + DefaultPrefixLen: model.IPv4DefaultPrefixLength, + MaxPrefixLen: model.IPv4MaxPrefixLength, + MinPrefixLen: model.IPv4MinPrefixLength, + }, + } + return req.UpdateNetworkAreaRegionPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat, region, networkAreaLabel string, regionalArea iaas.RegionalArea) error { + return p.OutputResult(outputFormat, regionalArea, func() error { + p.Outputf("Updated region configuration for SNA %q.\nRegion: %s\n", networkAreaLabel, region) + return nil + }) +} diff --git a/internal/cmd/network-area/region/update/update_test.go b/internal/cmd/network-area/region/update/update_test.go new file mode 100644 index 000000000..11494fbf0 --- /dev/null +++ b/internal/cmd/network-area/region/update/update_test.go @@ -0,0 +1,267 @@ +package update + +import ( + "context" + "strconv" + "strings" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testRegion = "eu01" + testDefaultPrefixLength int64 = 25 + testMaxPrefixLength int64 = 29 + testMinPrefixLength int64 = 24 +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +var ( + testAreaId = uuid.NewString() + testOrgId = uuid.NewString() + testDefaultNameservers = []string{"8.8.8.8", "8.8.4.4"} + testNetworkRanges = []string{"192.168.0.0/24", "10.0.0.0/24"} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + + networkAreaIdFlag: testAreaId, + organizationIdFlag: testOrgId, + ipv4DefaultNameservers: strings.Join(testDefaultNameservers, ","), + ipv4DefaultPrefixLengthFlag: strconv.FormatInt(testDefaultPrefixLength, 10), + ipv4MaxPrefixLengthFlag: strconv.FormatInt(testMaxPrefixLength, 10), + ipv4MinPrefixLengthFlag: strconv.FormatInt(testMinPrefixLength, 10), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrgId, + NetworkAreaId: testAreaId, + IPv4DefaultNameservers: utils.Ptr(testDefaultNameservers), + IPv4DefaultPrefixLength: utils.Ptr(testDefaultPrefixLength), + IPv4MaxPrefixLength: utils.Ptr(testMaxPrefixLength), + IPv4MinPrefixLength: utils.Ptr(testMinPrefixLength), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateNetworkAreaRegionRequest)) iaas.ApiUpdateNetworkAreaRegionRequest { + request := testClient.UpdateNetworkAreaRegion(testCtx, testOrgId, testAreaId, testRegion) + request = request.UpdateNetworkAreaRegionPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.UpdateNetworkAreaRegionPayload)) iaas.UpdateNetworkAreaRegionPayload { + var networkRange []iaas.NetworkRange + if len(testNetworkRanges) > 0 { + networkRange = make([]iaas.NetworkRange, len(testNetworkRanges)) + for i := range testNetworkRanges { + networkRange[i] = iaas.NetworkRange{ + Prefix: utils.Ptr(testNetworkRanges[i]), + } + } + } + + payload := iaas.UpdateNetworkAreaRegionPayload{ + Ipv4: &iaas.UpdateRegionalAreaIPv4{ + DefaultNameservers: utils.Ptr(testDefaultNameservers), + DefaultPrefixLen: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLen: utils.Ptr(testMaxPrefixLength), + MinPrefixLen: utils.Ptr(testMinPrefixLength), + }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []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: "org id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "org id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "org id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "area id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "area id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "area id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "no update data is set", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, ipv4DefaultPrefixLengthFlag) + delete(flagValues, ipv4MaxPrefixLengthFlag) + delete(flagValues, ipv4MinPrefixLengthFlag) + delete(flagValues, ipv4DefaultNameservers) + }), + isValid: false, + }, + { + description: "region empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.RegionFlag] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateNetworkAreaRegionRequest + }{ + { + 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) + } + }) + } +} + +func Test_outputResult(t *testing.T) { + type args struct { + outputFormat string + region string + networkAreaLabel string + regionalArea iaas.RegionalArea + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "set empty regional area", + args: args{ + regionalArea: iaas.RegionalArea{}, + }, + wantErr: false, + }, + { + name: "output json", + args: args{ + outputFormat: print.JSONOutputFormat, + regionalArea: iaas.RegionalArea{}, + }, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.region, tt.args.networkAreaLabel, tt.args.regionalArea); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/route/create/create.go b/internal/cmd/network-area/route/create/create.go index 05a881052..7728da988 100644 --- a/internal/cmd/network-area/route/create/create.go +++ b/internal/cmd/network-area/route/create/create.go @@ -2,10 +2,14 @@ package create import ( "context" - "encoding/json" "fmt" + "net" + "os" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/goccy/go-yaml" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -22,21 +25,42 @@ import ( const ( organizationIdFlag = "organization-id" networkAreaIdFlag = "network-area-id" - prefixFlag = "prefix" - nexthopFlag = "next-hop" - labelFlag = "labels" + // Deprecated: prefixFlag is deprecated and will be removed after April 2026. Use instead destinationFlag + prefixFlag = "prefix" + destinationFlag = "destination" + // Deprecated: nexthopFlag is deprecated and will be removed after April 2026. Use instead nexthopIPv4Flag or nexthopIPv6Flag + nexthopFlag = "next-hop" + nexthopIPv4Flag = "next-hop-ipv4" + nexthopIPv6Flag = "next-hop-ipv6" + nexthopBlackholeFlag = "nexthop-blackhole" + nexthopInternetFlag = "nexthop-internet" + labelFlag = "labels" +) + +const ( + destinationCIDRv4Type = "cidrv4" + destinationCIDRv6Type = "cidrv6" + + nexthopBlackholeType = "blackhole" + nexthopInternetType = "internet" + nexthopIPv4Type = "ipv4" + nexthopIPv6Type = "ipv6" ) type inputModel struct { *globalflags.GlobalFlagModel - OrganizationId *string - NetworkAreaId *string - Prefix *string - Nexthop *string - Labels *map[string]string + OrganizationId *string + NetworkAreaId *string + DestinationV4 *string + DestinationV6 *string + NexthopV4 *string + NexthopV6 *string + NexthopBlackhole *bool + NexthopInternet *bool + Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a static route in a STACKIT Network Area (SNA)", @@ -47,23 +71,23 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `Create a static route with prefix "1.1.1.0/24" and next hop "1.1.1.1" in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`, - "$ stackit network-area route create --organization-id yyy --network-area-id xxx --prefix 1.1.1.0/24 --next-hop 1.1.1.1", + `Create a static route with destination "1.1.1.0/24" and next hop "1.1.1.1" in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`, + "$ stackit network-area route create --organization-id yyy --network-area-id xxx --destination 1.1.1.0/24 --next-hop 1.1.1.1", ), examples.NewExample( - `Create a static route with labels "key:value" and "foo:bar" with prefix "1.1.1.0/24" and next hop "1.1.1.1" in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`, - "$ stackit network-area route create --labels key=value,foo=bar --organization-id yyy --network-area-id xxx --prefix 1.1.1.0/24 --next-hop 1.1.1.1", + `Create a static route with labels "key:value" and "foo:bar" with destination "1.1.1.0/24" and next hop "1.1.1.1" in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`, + "$ stackit network-area route create --labels key=value,foo=bar --organization-id yyy --network-area-id xxx --destination 1.1.1.0/24 --next-hop 1.1.1.1", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -71,18 +95,14 @@ func NewCmd(p *print.Printer) *cobra.Command { // Get network area label networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId) if err != nil { - p.Debug(print.ErrorLevel, "get network area name: %v", err) - networkAreaLabel = *model.NetworkAreaId - } else if networkAreaLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) networkAreaLabel = *model.NetworkAreaId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a static route for STACKIT Network Area (SNA) %q?", networkAreaLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a static route for STACKIT Network Area (SNA) %q?", networkAreaLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -96,12 +116,32 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("empty response from API") } - route, err := iaasUtils.GetRouteFromAPIResponse(*model.Prefix, *model.Nexthop, resp.Items) + var destination string + var nexthop string + if model.DestinationV4 != nil { + destination = *model.DestinationV4 + } else if model.DestinationV6 != nil { + destination = *model.DestinationV6 + } + + if model.NexthopV4 != nil { + nexthop = *model.NexthopV4 + } else if model.NexthopV6 != nil { + nexthop = *model.NexthopV6 + } else if model.NexthopBlackhole != nil { + // For nexthopBlackhole the type is assigned to nexthop, because it doesn't have any value + nexthop = nexthopBlackholeType + } else if model.NexthopInternet != nil { + // For nexthopInternet the type is assigned to nexthop, because it doesn't have any value + nexthop = nexthopInternetType + } + + route, err := iaasUtils.GetRouteFromAPIResponse(destination, nexthop, resp.Items) if err != nil { return err } - return outputResult(p, model.OutputFormat, networkAreaLabel, route) + return outputResult(params.Printer, model.OutputFormat, networkAreaLabel, route) }, } configureFlags(cmd) @@ -112,55 +152,146 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area ID") cmd.Flags().Var(flags.CIDRFlag(), prefixFlag, "Static route prefix") - cmd.Flags().String(nexthopFlag, "", "Next hop IP address. Must be a valid IPv4") + cmd.Flags().Var(flags.CIDRFlag(), destinationFlag, "Destination route. Must be a valid IPv4 or IPv6 CIDR") + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a route. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels") + cmd.Flags().String(nexthopFlag, "", "Next hop IP address. Must be a valid IPv4") + cmd.Flags().String(nexthopIPv4Flag, "", "Next hop IPv4 address") + cmd.Flags().String(nexthopIPv6Flag, "", "Next hop IPv6 address") + cmd.Flags().Bool(nexthopBlackholeFlag, false, "Sets next hop to black hole") + cmd.Flags().Bool(nexthopInternetFlag, false, "Sets next hop to internet") + + cobra.CheckErr(cmd.Flags().MarkDeprecated(nexthopFlag, fmt.Sprintf("The flag %q is deprecated and will be removed after April 2026. Use instead %q to configure a IPv4 next hop.", nexthopFlag, nexthopBlackholeFlag))) + cobra.CheckErr(cmd.Flags().MarkDeprecated(prefixFlag, fmt.Sprintf("The flag %q is deprecated and will be removed after April 2026. Use instead %q to configure a destination.", prefixFlag, destinationFlag))) + // Set the output for deprecation warnings to stderr + cmd.Flags().SetOutput(os.Stderr) + + destinationFlags := []string{prefixFlag, destinationFlag} + nexthopFlags := []string{nexthopFlag, nexthopIPv4Flag, nexthopIPv6Flag, nexthopBlackholeFlag, nexthopInternetFlag} + cmd.MarkFlagsMutuallyExclusive(destinationFlags...) + cmd.MarkFlagsMutuallyExclusive(nexthopFlags...) - err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, prefixFlag, nexthopFlag) + cmd.MarkFlagsOneRequired(destinationFlags...) + cmd.MarkFlagsOneRequired(nexthopFlags...) + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseDestination(input string) (destinationV4, destinationV6 *string, err error) { + ip, _, err := net.ParseCIDR(input) + if err != nil { + return nil, nil, fmt.Errorf("parse CIDR: %w", err) + } + if ip.To4() != nil { // CIDR is IPv4 + destinationV4 = utils.Ptr(input) + return destinationV4, nil, nil + } + // CIDR is IPv6 + destinationV6 = utils.Ptr(input) + return nil, destinationV6, nil +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) - model := inputModel{ - GlobalFlagModel: globalFlags, - OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), - NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), - Prefix: flags.FlagToStringPointer(p, cmd, prefixFlag), - Nexthop: flags.FlagToStringPointer(p, cmd, nexthopFlag), - Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + var destinationV4, destinationV6 *string + if destination := flags.FlagToStringPointer(p, cmd, destinationFlag); destination != nil { + var err error + destinationV4, destinationV6, err = parseDestination(*destination) + if err != nil { + return nil, err + } } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) + if prefix := flags.FlagToStringPointer(p, cmd, prefixFlag); prefix != nil { + var err error + destinationV4, destinationV6, err = parseDestination(*prefix) if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + return nil, err } } + nexthopIPv4 := flags.FlagToStringPointer(p, cmd, nexthopIPv4Flag) + nexthopIPv6 := flags.FlagToStringPointer(p, cmd, nexthopIPv6Flag) + nexthopInternet := flags.FlagToBoolPointer(p, cmd, nexthopInternetFlag) + nexthopBlackhole := flags.FlagToBoolPointer(p, cmd, nexthopBlackholeFlag) + if nexthop := flags.FlagToStringPointer(p, cmd, nexthopFlag); nexthop != nil { + nexthopIPv4 = nexthop + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag), + NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), + DestinationV4: destinationV4, + DestinationV6: destinationV6, + NexthopV4: nexthopIPv4, + NexthopV6: nexthopIPv6, + NexthopBlackhole: nexthopBlackhole, + NexthopInternet: nexthopInternet, + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + } + + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateNetworkAreaRouteRequest { - req := apiClient.CreateNetworkAreaRoute(ctx, *model.OrganizationId, *model.NetworkAreaId) - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v + req := apiClient.CreateNetworkAreaRoute(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region) + + var destinationV4 *iaas.DestinationCIDRv4 + var destinationV6 *iaas.DestinationCIDRv6 + if model.DestinationV4 != nil { + destinationV4 = &iaas.DestinationCIDRv4{ + Type: utils.Ptr(destinationCIDRv4Type), + Value: model.DestinationV4, + } + } + if model.DestinationV6 != nil { + destinationV6 = &iaas.DestinationCIDRv6{ + Type: utils.Ptr(destinationCIDRv6Type), + Value: model.DestinationV6, + } + } + + var nexthopIPv4 *iaas.NexthopIPv4 + var nexthopIPv6 *iaas.NexthopIPv6 + var nexthopBlackhole *iaas.NexthopBlackhole + var nexthopInternet *iaas.NexthopInternet + + if model.NexthopV4 != nil { + nexthopIPv4 = &iaas.NexthopIPv4{ + Type: utils.Ptr(nexthopIPv4Type), + Value: model.NexthopV4, + } + } else if model.NexthopV6 != nil { + nexthopIPv6 = &iaas.NexthopIPv6{ + Type: utils.Ptr(nexthopIPv6Type), + Value: model.NexthopV6, + } + } else if model.NexthopBlackhole != nil { + nexthopBlackhole = &iaas.NexthopBlackhole{ + Type: utils.Ptr(nexthopBlackholeType), + } + } else if model.NexthopInternet != nil { + nexthopInternet = &iaas.NexthopInternet{ + Type: utils.Ptr(nexthopInternetType), } } payload := iaas.CreateNetworkAreaRoutePayload{ - Ipv4: &[]iaas.Route{ + Items: &[]iaas.Route{ { - Prefix: model.Prefix, - Nexthop: model.Nexthop, - Labels: labelsMap, + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: destinationV4, + DestinationCIDRv6: destinationV6, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: nexthopIPv4, + NexthopIPv6: nexthopIPv6, + NexthopBlackhole: nexthopBlackhole, + NexthopInternet: nexthopInternet, + }, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), }, }, } @@ -168,25 +299,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat, networkAreaLabel string, route iaas.Route) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(route, "", " ") - if err != nil { - return fmt.Errorf("marshal static route: %w", err) - } - p.Outputln(string(details)) - + return p.OutputResult(outputFormat, route, func() error { + p.Outputf("Created static route for SNA %q.\nStatic route ID: %s\n", networkAreaLabel, utils.PtrString(route.Id)) return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(route, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal static route: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - p.Outputf("Created static route for SNA %q.\nStatic route ID: %s\n", networkAreaLabel, utils.PtrString(route.RouteId)) - return nil - } + }) } diff --git a/internal/cmd/network-area/route/create/create_test.go b/internal/cmd/network-area/route/create/create_test.go index 4ffca7666..497c31a66 100644 --- a/internal/cmd/network-area/route/create/create_test.go +++ b/internal/cmd/network-area/route/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,6 +17,12 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" + testDestinationCIDRv4 = "1.1.1.0/24" + testNexthopIPv4 = "1.1.1.1" +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -24,10 +33,12 @@ var testNetworkAreaId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, networkAreaIdFlag: testNetworkAreaId, - prefixFlag: "1.1.1.0/24", - nexthopFlag: "1.1.1.1", + destinationFlag: testDestinationCIDRv4, + nexthopIPv4Flag: testNexthopIPv4, } for _, mod := range mods { mod(flagValues) @@ -39,11 +50,12 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, OrganizationId: utils.Ptr(testOrgId), NetworkAreaId: utils.Ptr(testNetworkAreaId), - Prefix: utils.Ptr("1.1.1.0/24"), - Nexthop: utils.Ptr("1.1.1.1"), + DestinationV4: utils.Ptr(testDestinationCIDRv4), + NexthopV4: utils.Ptr(testNexthopIPv4), } for _, mod := range mods { mod(model) @@ -52,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkAreaRouteRequest)) iaas.ApiCreateNetworkAreaRouteRequest { - request := testClient.CreateNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId) + request := testClient.CreateNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRegion) request = request.CreateNetworkAreaRoutePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -62,10 +74,20 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkAreaRouteRequest) func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaRoutePayload)) iaas.CreateNetworkAreaRoutePayload { payload := iaas.CreateNetworkAreaRoutePayload{ - Ipv4: &[]iaas.Route{ + Items: &[]iaas.Route{ { - Prefix: utils.Ptr("1.1.1.0/24"), - Nexthop: utils.Ptr("1.1.1.1"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(destinationCIDRv4Type), + Value: utils.Ptr(testDestinationCIDRv4), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(nexthopIPv4Type), + Value: utils.Ptr(testNexthopIPv4), + }, + }, }, }, } @@ -78,6 +100,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaRoutePayload)) i func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -93,7 +116,7 @@ func TestParseInput(t *testing.T) { { description: "next hop missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, nexthopFlag) + delete(flagValues, nexthopIPv4Flag) }), isValid: false, }, @@ -145,23 +168,23 @@ func TestParseInput(t *testing.T) { isValid: false, }, { - description: "prefix missing", + description: "destination missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, prefixFlag) + delete(flagValues, destinationFlag) }), isValid: false, }, { - description: "prefix invalid 1", + description: "destinationFlag invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[prefixFlag] = "" + flagValues[destinationFlag] = "" }), isValid: false, }, { - description: "prefix invalid 2", + description: "destinationFlag invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[prefixFlag] = "invalid-prefix" + flagValues[destinationFlag] = "invalid-destinationFlag" }), isValid: false, }, @@ -175,50 +198,28 @@ func TestParseInput(t *testing.T) { }), isValid: true, }, + { + description: "conflicting destination and prefix set", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[prefixFlag] = testDestinationCIDRv4 + }), + isValid: false, + }, + { + description: "conflicting nexthop and nexthop-ipv4 set", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nexthopFlag] = testNexthopIPv4 + }), + isValid: false, + }, + { + description: "conflicting nexthop and nexthop-ipv4 set", + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -240,8 +241,8 @@ func TestBuildRequest(t *testing.T) { model.Labels = utils.Ptr(map[string]string{"key": "value"}) }), expectedRequest: fixtureRequest(func(request *iaas.ApiCreateNetworkAreaRouteRequest) { - *request = request.CreateNetworkAreaRoutePayload(fixturePayload(func(payload *iaas.CreateNetworkAreaRoutePayload) { - (*payload.Ipv4)[0].Labels = utils.Ptr(map[string]interface{}{"key": "value"}) + *request = (*request).CreateNetworkAreaRoutePayload(fixturePayload(func(payload *iaas.CreateNetworkAreaRoutePayload) { + (*payload.Items)[0].Labels = utils.Ptr(map[string]interface{}{"key": "value"}) })) }), }, @@ -287,7 +288,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.networkAreaLabel, tt.args.route); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/route/delete/delete.go b/internal/cmd/network-area/route/delete/delete.go index 144647440..55ec64472 100644 --- a/internal/cmd/network-area/route/delete/delete.go +++ b/internal/cmd/network-area/route/delete/delete.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -12,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -31,7 +34,7 @@ type inputModel struct { RouteId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", routeIdArg), Short: "Deletes a static route in a STACKIT Network Area (SNA)", @@ -45,31 +48,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId) if err != nil { - p.Debug(print.ErrorLevel, "get network area name: %v", err) - networkAreaLabel = *model.NetworkAreaId - } else if networkAreaLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) networkAreaLabel = *model.NetworkAreaId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete static route %q on STACKIT Network Area (SNA) %q?", model.RouteId, networkAreaLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete static route %q on STACKIT Network Area (SNA) %q?", model.RouteId, networkAreaLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -79,7 +78,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete static route: %w", err) } - p.Info("Deleted static route %q on SNA %q\n", model.RouteId, networkAreaLabel) + params.Printer.Info("Deleted static route %q on SNA %q\n", model.RouteId, networkAreaLabel) return nil }, } @@ -106,19 +105,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu RouteId: routeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteNetworkAreaRouteRequest { - req := apiClient.DeleteNetworkAreaRoute(ctx, *model.OrganizationId, *model.NetworkAreaId, model.RouteId) + req := apiClient.DeleteNetworkAreaRoute(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region, model.RouteId) return req } diff --git a/internal/cmd/network-area/route/delete/delete_test.go b/internal/cmd/network-area/route/delete/delete_test.go index 8a63e08c7..6352be04a 100644 --- a/internal/cmd/network-area/route/delete/delete_test.go +++ b/internal/cmd/network-area/route/delete/delete_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,6 +16,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,6 +41,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, networkAreaIdFlag: testNetworkAreaId, } @@ -48,6 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, OrganizationId: utils.Ptr(testOrgId), NetworkAreaId: utils.Ptr(testNetworkAreaId), @@ -60,7 +69,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteNetworkAreaRouteRequest)) iaas.ApiDeleteNetworkAreaRouteRequest { - request := testClient.DeleteNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRouteId) + request := testClient.DeleteNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRegion, testRouteId) for _, mod := range mods { mod(&request) } @@ -156,7 +165,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/network-area/route/describe/describe.go b/internal/cmd/network-area/route/describe/describe.go index 8708f12e1..070b47738 100644 --- a/internal/cmd/network-area/route/describe/describe.go +++ b/internal/cmd/network-area/route/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -34,7 +35,7 @@ type inputModel struct { RouteId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", routeIdArg), Short: "Shows details of a static route in a STACKIT Network Area (SNA)", @@ -52,13 +53,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -70,7 +71,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe static route: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -96,48 +97,52 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu RouteId: routeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetNetworkAreaRouteRequest { - req := apiClient.GetNetworkAreaRoute(ctx, *model.OrganizationId, *model.NetworkAreaId, model.RouteId) + req := apiClient.GetNetworkAreaRoute(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region, model.RouteId) return req } func outputResult(p *print.Printer, outputFormat string, route iaas.Route) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(route, "", " ") - if err != nil { - return fmt.Errorf("marshal static route: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(route, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal static route: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, route, func() error { table := tables.NewTable() - table.AddRow("ID", utils.PtrString(route.RouteId)) - table.AddSeparator() - table.AddRow("PREFIX", utils.PtrString(route.Prefix)) + table.AddRow("ID", utils.PtrString(route.Id)) table.AddSeparator() - table.AddRow("NEXTHOP", utils.PtrString(route.Nexthop)) + if destination := route.Destination; destination != nil { + if destination.DestinationCIDRv4 != nil { + table.AddRow("DESTINATION TYPE", utils.PtrString(destination.DestinationCIDRv4.Type)) + table.AddSeparator() + table.AddRow("DESTINATION", utils.PtrString(destination.DestinationCIDRv4.Value)) + table.AddSeparator() + } else if destination.DestinationCIDRv6 != nil { + table.AddRow("DESTINATION TYPE", utils.PtrString(destination.DestinationCIDRv6.Type)) + table.AddSeparator() + table.AddRow("DESTINATION", utils.PtrString(destination.DestinationCIDRv6.Value)) + table.AddSeparator() + } + } + if nexthop := route.Nexthop; nexthop != nil { + if nexthop.NexthopIPv4 != nil { + table.AddRow("NEXTHOP", utils.PtrString(nexthop.NexthopIPv4.Value)) + table.AddSeparator() + table.AddRow("NEXTHOP TYPE", utils.PtrString(nexthop.NexthopIPv4.Type)) + table.AddSeparator() + } else if nexthop.NexthopIPv6 != nil { + table.AddRow("NEXTHOP", utils.PtrString(nexthop.NexthopIPv6.Value)) + table.AddSeparator() + table.AddRow("NEXTHOP TYPE", utils.PtrString(nexthop.NexthopIPv6.Type)) + table.AddSeparator() + } else if nexthop.NexthopBlackhole != nil { + table.AddRow("NEXTHOP TYPE", utils.PtrString(nexthop.NexthopBlackhole.Type)) + table.AddSeparator() + } else if nexthop.NexthopInternet != nil { + table.AddRow("NEXTHOP TYPE", utils.PtrString(nexthop.NexthopInternet.Type)) + table.AddSeparator() + } + } if route.Labels != nil && len(*route.Labels) > 0 { labels := []string{} for key, value := range *route.Labels { @@ -152,5 +157,5 @@ func outputResult(p *print.Printer, outputFormat string, route iaas.Route) error return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/network-area/route/describe/describe_test.go b/internal/cmd/network-area/route/describe/describe_test.go index d052d69f9..3923e2b26 100644 --- a/internal/cmd/network-area/route/describe/describe_test.go +++ b/internal/cmd/network-area/route/describe/describe_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,6 +16,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,6 +41,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, networkAreaIdFlag: testNetworkAreaId, } @@ -48,6 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, OrganizationId: utils.Ptr(testOrgId), NetworkAreaId: utils.Ptr(testNetworkAreaId), @@ -60,7 +69,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetNetworkAreaRouteRequest)) iaas.ApiGetNetworkAreaRouteRequest { - request := testClient.GetNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRouteId) + request := testClient.GetNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRegion, testRouteId) for _, mod := range mods { mod(&request) } @@ -156,7 +165,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -259,7 +268,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.route); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/route/list/list.go b/internal/cmd/network-area/route/list/list.go index f8bada766..8cadbdef7 100644 --- a/internal/cmd/network-area/route/list/list.go +++ b/internal/cmd/network-area/route/list/list.go @@ -2,13 +2,15 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -17,7 +19,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -33,7 +34,7 @@ type inputModel struct { NetworkAreaId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all static routes in a STACKIT Network Area (SNA)", @@ -53,15 +54,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit network-area route list --network-area-id xxx --organization-id yyy --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -77,12 +78,10 @@ func NewCmd(p *print.Printer) *cobra.Command { var networkAreaLabel string networkAreaLabel, err = iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId) if err != nil { - p.Debug(print.ErrorLevel, "get network area name: %v", err) - networkAreaLabel = *model.NetworkAreaId - } else if networkAreaLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) networkAreaLabel = *model.NetworkAreaId } - p.Info("No static routes found for STACKIT Network Area %q\n", networkAreaLabel) + params.Printer.Info("No static routes found for STACKIT Network Area %q\n", networkAreaLabel) return nil } @@ -92,7 +91,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -108,11 +107,11 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) if limit != nil && *limit < 1 { - return nil, &errors.FlagValidationError{ + return nil, &cliErr.FlagValidationError{ Flag: limitFlag, Details: "must be greater than 0", } @@ -125,53 +124,54 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListNetworkAreaRoutesRequest { - return apiClient.ListNetworkAreaRoutes(ctx, *model.OrganizationId, *model.NetworkAreaId) + return apiClient.ListNetworkAreaRoutes(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region) } func outputResult(p *print.Printer, outputFormat string, routes []iaas.Route) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(routes, "", " ") - if err != nil { - return fmt.Errorf("marshal static routes: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(routes, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal static routes: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, routes, func() error { table := tables.NewTable() - table.SetHeader("Static Route ID", "Next Hop", "Prefix") + table.SetHeader("Static Route ID", "Next Hop", "Next Hop Type", "Destination") for _, route := range routes { + var nextHop string + var nextHopType string + var destination string + if routeDest := route.Destination; routeDest != nil { + if routeDest.DestinationCIDRv4 != nil { + destination = *routeDest.DestinationCIDRv4.Value + } + if routeDest.DestinationCIDRv6 != nil { + destination = *routeDest.DestinationCIDRv6.Value + } + } + if routeNexthop := route.Nexthop; routeNexthop != nil { + if routeNexthop.NexthopIPv4 != nil { + nextHop = *routeNexthop.NexthopIPv4.Value + nextHopType = *routeNexthop.NexthopIPv4.Type + } else if routeNexthop.NexthopIPv6 != nil { + nextHop = *routeNexthop.NexthopIPv6.Value + nextHopType = *routeNexthop.NexthopIPv6.Type + } else if routeNexthop.NexthopBlackhole != nil { + nextHopType = *routeNexthop.NexthopBlackhole.Type + } else if routeNexthop.NexthopInternet != nil { + nextHopType = *routeNexthop.NexthopInternet.Type + } + } + table.AddRow( - utils.PtrString(route.RouteId), - utils.PtrString(route.Nexthop), - utils.PtrString(route.Prefix), + utils.PtrString(route.Id), + nextHop, + nextHopType, + destination, ) } p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/network-area/route/list/list_test.go b/internal/cmd/network-area/route/list/list_test.go index 573b332a8..f40f9bafe 100644 --- a/internal/cmd/network-area/route/list/list_test.go +++ b/internal/cmd/network-area/route/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,6 +17,10 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -23,6 +30,8 @@ var testNetworkAreaId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrganizationId, networkAreaIdFlag: testNetworkAreaId, limitFlag: "10", @@ -37,6 +46,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, OrganizationId: &testOrganizationId, NetworkAreaId: &testNetworkAreaId, @@ -49,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreaRoutesRequest)) iaas.ApiListNetworkAreaRoutesRequest { - request := testClient.ListNetworkAreaRoutes(testCtx, testOrganizationId, testNetworkAreaId) + request := testClient.ListNetworkAreaRoutes(testCtx, testOrganizationId, testNetworkAreaId, testRegion) for _, mod := range mods { mod(&request) } @@ -59,6 +69,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreaRoutesRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -139,46 +150,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -240,9 +212,27 @@ func TestOutputResult(t *testing.T) { }, wantErr: false, }, + { + name: "empty destination in route", + args: args{ + routes: []iaas.Route{{ + Destination: &iaas.RouteDestination{}, + }}, + }, + wantErr: false, + }, + { + name: "empty nexthop in route", + args: args{ + routes: []iaas.Route{{ + Nexthop: &iaas.RouteNexthop{}, + }}, + }, + wantErr: false, + }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.routes); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/route/routes.go b/internal/cmd/network-area/route/routes.go index 20fa115dd..f6d2b3656 100644 --- a/internal/cmd/network-area/route/routes.go +++ b/internal/cmd/network-area/route/routes.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/route/list" "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/route/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "route", Short: "Provides functionality for static routes in STACKIT Network Areas", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/network-area/route/update/update.go b/internal/cmd/network-area/route/update/update.go index b97c9b44d..2a422a1d4 100644 --- a/internal/cmd/network-area/route/update/update.go +++ b/internal/cmd/network-area/route/update/update.go @@ -2,13 +2,15 @@ package update import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -36,7 +37,7 @@ type inputModel struct { Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", routeIdArg), Short: "Updates a static route in a STACKIT Network Area (SNA)", @@ -53,13 +54,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,9 +68,7 @@ func NewCmd(p *print.Printer) *cobra.Command { // Get network area label networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId) if err != nil { - p.Debug(print.ErrorLevel, "get network area name: %v", err) - networkAreaLabel = *model.NetworkAreaId - } else if networkAreaLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err) networkAreaLabel = *model.NetworkAreaId } @@ -80,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create static route: %w", err) } - return outputResult(p, model.OutputFormat, networkAreaLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, networkAreaLabel, *resp) }, } configureFlags(cmd) @@ -103,7 +102,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu labels := flags.FlagToStringToStringPointer(p, cmd, labelFlag) if labels == nil { - return nil, &errors.EmptyUpdateError{} + return nil, &cliErr.EmptyUpdateError{} } model := inputModel{ @@ -114,29 +113,15 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Labels: labels, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateNetworkAreaRouteRequest { - req := apiClient.UpdateNetworkAreaRoute(ctx, *model.OrganizationId, *model.NetworkAreaId, model.RouteId) - - // convert map[string]string to map[string]interface{} - labelsMap := make(map[string]interface{}) - for k, v := range *model.Labels { - labelsMap[k] = v - } + req := apiClient.UpdateNetworkAreaRoute(ctx, *model.OrganizationId, *model.NetworkAreaId, model.Region, model.RouteId) payload := iaas.UpdateNetworkAreaRoutePayload{ - Labels: &labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } req = req.UpdateNetworkAreaRoutePayload(payload) @@ -144,25 +129,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat, networkAreaLabel string, route iaas.Route) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(route, "", " ") - if err != nil { - return fmt.Errorf("marshal static route: %w", err) - } - p.Outputln(string(details)) - + return p.OutputResult(outputFormat, route, func() error { + p.Outputf("Updated static route for SNA %q.\nStatic route ID: %s\n", networkAreaLabel, utils.PtrString(route.Id)) return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(route, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal static route: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - p.Outputf("Updated static route for SNA %q.\nStatic route ID: %s\n", networkAreaLabel, utils.PtrString(route.RouteId)) - return nil - } + }) } diff --git a/internal/cmd/network-area/route/update/update_test.go b/internal/cmd/network-area/route/update/update_test.go index 813deae4d..11a47b15c 100644 --- a/internal/cmd/network-area/route/update/update_test.go +++ b/internal/cmd/network-area/route/update/update_test.go @@ -4,13 +4,20 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + testRegion = "eu01" ) type testCtxKey struct{} @@ -34,6 +41,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, networkAreaIdFlag: testNetworkAreaId, labelFlag: "value=key", @@ -73,6 +82,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, OrganizationId: utils.Ptr(testOrgId), NetworkAreaId: utils.Ptr(testNetworkAreaId), @@ -86,7 +96,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUpdateNetworkAreaRouteRequest)) iaas.ApiUpdateNetworkAreaRouteRequest { - request := testClient.UpdateNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRouteId) + request := testClient.UpdateNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRegion, testRouteId) request = request.UpdateNetworkAreaRoutePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -189,7 +199,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -293,7 +303,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.networkAreaLabel, tt.args.route); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-area/routingtable/create/create.go b/internal/cmd/network-area/routingtable/create/create.go new file mode 100644 index 000000000..aed268466 --- /dev/null +++ b/internal/cmd/network-area/routingtable/create/create.go @@ -0,0 +1,161 @@ +package create + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + descriptionFlag = "description" + labelFlag = "labels" + nameFlag = "name" + networkAreaIdFlag = "network-area-id" + dynamicRoutesFlag = "dynamic-routes" + systemRoutesFlag = "system-routes" + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Description *string + Labels *map[string]string + Name string + NetworkAreaId string + SystemRoutes bool + DynamicRoutes bool + OrganizationId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a routing-table", + Long: "Creates a routing-table.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a routing-table with name "rt"`, + `$ stackit network-area routing-table create --organization-id xxx --network-area-id yyy --name "rt"`, + ), + examples.NewExample( + `Create a routing-table with name "rt" and description "some description"`, + `$ stackit network-area routing-table create --organization-id xxx --network-area-id yyy --name "rt" --description "some description"`, + ), + examples.NewExample( + `Create a routing-table with name "rt" with system routes disabled`, + `$ stackit network-area routing-table create --organization-id xxx --network-area-id yyy --name "rt" --system-routes=false`, + ), + examples.NewExample( + `Create a routing-table with name "rt" with dynamic routes disabled`, + `$ stackit network-area routing-table create --organization-id xxx --network-area-id yyy --name "rt" --dynamic-routes=false`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + prompt := fmt.Sprintf("Are you sure you want to create the routing-table %q?", model.Name) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + routingTableResp, err := req.Execute() + if err != nil { + return fmt.Errorf("create routing-table request failed: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, routingTableResp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(descriptionFlag, "", "Description of the routing-table") + cmd.Flags().StringToString(labelFlag, nil, "Key=value labels") + cmd.Flags().String(nameFlag, "", "Name of the routing-table") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Bool(dynamicRoutesFlag, true, "If set to false, prevents dynamic routes from propagating to the routing table.") + cmd.Flags().Bool(systemRoutesFlag, true, "If set to false, disables routes for project-to-project communication.") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := &inputModel{ + GlobalFlagModel: globalFlags, + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + DynamicRoutes: flags.FlagToBoolValue(p, cmd, dynamicRoutesFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + Name: flags.FlagToStringValue(p, cmd, nameFlag), + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + SystemRoutes: flags.FlagToBoolValue(p, cmd, systemRoutesFlag), + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) (iaas.ApiAddRoutingTableToAreaRequest, error) { + payload := iaas.AddRoutingTableToAreaPayload{ + Description: model.Description, + Name: utils.Ptr(model.Name), + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + SystemRoutes: utils.Ptr(model.SystemRoutes), + DynamicRoutes: utils.Ptr(model.DynamicRoutes), + } + + return apiClient.AddRoutingTableToArea( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + ).AddRoutingTableToAreaPayload(payload), nil +} + +func outputResult(p *print.Printer, outputFormat string, routingTable *iaas.RoutingTable) error { + if routingTable == nil { + return fmt.Errorf("routing-table is nil") + } + + if routingTable.Id == nil { + return fmt.Errorf("create routing-table id is empty") + } + + return p.OutputResult(outputFormat, routingTable, func() error { + p.Outputf("Created Routing-Table with ID %q\n", utils.PtrString(routingTable.Id)) + return nil + }) +} diff --git a/internal/cmd/network-area/routingtable/create/create_test.go b/internal/cmd/network-area/routingtable/create/create_test.go new file mode 100644 index 000000000..2ff9fa52b --- /dev/null +++ b/internal/cmd/network-area/routingtable/create/create_test.go @@ -0,0 +1,349 @@ +package create + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() + +const testRoutingTableName = "test" +const testRoutingTableDescription = "test" + +const testSystemRoutesFlag = true +const testDynamicRoutesFlag = true + +const testLabelSelectorFlag = "key1=value1,key2=value2" + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + descriptionFlag: testRoutingTableDescription, + nameFlag: testRoutingTableName, + systemRoutesFlag: strconv.FormatBool(testSystemRoutesFlag), + dynamicRoutesFlag: strconv.FormatBool(testDynamicRoutesFlag), + labelFlag: testLabelSelectorFlag, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + Name: testRoutingTableName, + Description: utils.Ptr(testRoutingTableDescription), + SystemRoutes: testSystemRoutesFlag, + DynamicRoutes: testDynamicRoutesFlag, + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiAddRoutingTableToAreaRequest)) iaas.ApiAddRoutingTableToAreaRequest { + request := testClient.AddRoutingTableToArea(testCtx, testOrgId, testNetworkAreaId, testRegion) + request = request.AddRoutingTableToAreaPayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.AddRoutingTableToAreaPayload)) iaas.AddRoutingTableToAreaPayload { + payload := iaas.AddRoutingTableToAreaPayload{ + Description: utils.Ptr(testRoutingTableDescription), + Name: utils.Ptr(testRoutingTableName), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + SystemRoutes: utils.Ptr(true), + DynamicRoutes: utils.Ptr(true), + } + + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "dynamic routes disabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[dynamicRoutesFlag] = "false" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DynamicRoutes = false + }), + }, + { + description: "system routes disabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[systemRoutesFlag] = "false" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SystemRoutes = false + }), + }, + { + description: "missing organization ID", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "invalid organization ID - empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid organization ID - format", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing network area ID", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "invalid network area ID - empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid network area ID - format", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing name", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "missing labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + }, + { + description: "missing description", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, descriptionFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Description = nil + }), + }, + { + description: "no flags provided", + flagValues: map[string]string{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiAddRoutingTableToAreaRequest + }{ + { + description: "valid input", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "labels missing", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutingTableToAreaRequest) { + *request = (*request).AddRoutingTableToAreaPayload( + fixturePayload(func(payload *iaas.AddRoutingTableToAreaPayload) { + payload.Labels = nil + }), + ) + }), + }, + { + description: "system routes disabled", + model: fixtureInputModel(func(model *inputModel) { + model.SystemRoutes = false + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutingTableToAreaRequest) { + *request = (*request).AddRoutingTableToAreaPayload( + fixturePayload(func(payload *iaas.AddRoutingTableToAreaPayload) { + payload.SystemRoutes = utils.Ptr(false) + }), + ) + }), + }, + { + description: "dynamic routes disabled", + model: fixtureInputModel(func(model *inputModel) { + model.DynamicRoutes = false + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutingTableToAreaRequest) { + *request = (*request).AddRoutingTableToAreaPayload( + fixturePayload(func(payload *iaas.AddRoutingTableToAreaPayload) { + payload.DynamicRoutes = utils.Ptr(false) + }), + ) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("buildRequest returned error: %v", err) + } + + if diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx)); diff != "" { + t.Errorf("buildRequest() mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoutingTable := iaas.RoutingTable{ + Id: utils.Ptr("id-foo"), + Name: utils.Ptr("route-table-foo"), + Description: utils.Ptr("description-foo"), + SystemRoutes: utils.Ptr(true), + DynamicRoutes: utils.Ptr(true), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routingTable *iaas.RoutingTable + wantErr bool + }{ + { + name: "nil routing-table should return error", + outputFormat: "", + routingTable: nil, + wantErr: true, + }, + { + name: "empty routing-table", + outputFormat: print.PrettyOutputFormat, + routingTable: &iaas.RoutingTable{}, + wantErr: true, + }, + { + name: "pretty output routing-table", + outputFormat: print.PrettyOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + { + name: "json output routing-table", + outputFormat: print.JSONOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + { + name: "yaml output routing-table", + outputFormat: print.YAMLOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.routingTable); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/routingtable/delete/delete.go b/internal/cmd/network-area/routingtable/delete/delete.go new file mode 100644 index 000000000..1ae94658c --- /dev/null +++ b/internal/cmd/network-area/routingtable/delete/delete.go @@ -0,0 +1,117 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routingTableIdArg = "ROUTING_TABLE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NetworkAreaId string + OrganizationId string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", routingTableIdArg), + Short: "Deletes a routing-table", + Long: "Deletes a routing-table", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a routing-table with ID "xxx"`, + `$ stackit network-area routing-table delete xxx --organization-id yyy --network-area-id zzz`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + routingTableLabel, err := iaasUtils.GetRoutingTableOfAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region, model.RoutingTableId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get routing-table name: %v", err) + routingTableLabel = model.RoutingTableId + } else if routingTableLabel == "" { + routingTableLabel = model.RoutingTableId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the routing-table %q?", routingTableLabel) + + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := apiClient.DeleteRoutingTableFromArea( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + model.RoutingTableId, + ) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete routing-table: %w", err) + } + + params.Printer.Outputf("Routing-table %q deleted.", model.RoutingTableId) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + routingTableId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + RoutingTableId: routingTableId, + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/network-area/routingtable/delete/delete_test.go b/internal/cmd/network-area/routingtable/delete/delete_test.go new file mode 100644 index 000000000..0bf08b186 --- /dev/null +++ b/internal/cmd/network-area/routingtable/delete/delete_test.go @@ -0,0 +1,145 @@ +package delete + +import ( + "testing" + + "github.com/google/uuid" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const testRegion = "eu01" + +var ( + testOrgId = uuid.NewString() + testNetworkAreaId = uuid.NewString() + testRoutingTableId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + RoutingTableId: testRoutingTableId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(func(m *inputModel) { + m.RoutingTableId = testRoutingTableId + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing organization ID", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "invalid organization ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid organization ID - format", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing network area ID", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "invalid network area ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid network area ID - format", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing routing-table ID", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid routing-table ID - format", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid routing-table ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdArg] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} diff --git a/internal/cmd/network-area/routingtable/describe/describe.go b/internal/cmd/network-area/routingtable/describe/describe.go new file mode 100644 index 000000000..8292e3f9b --- /dev/null +++ b/internal/cmd/network-area/routingtable/describe/describe.go @@ -0,0 +1,152 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routingTableIdArg = "ROUTING_TABLE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NetworkAreaId string + OrganizationId string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", routingTableIdArg), + Short: "Describes a routing-table", + Long: "Describes a routing-table", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a routing-table`, + `$ stackit network-area routing-table describe xxx --organization-id xxx --network-area-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := apiClient.GetRoutingTableOfArea( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + model.RoutingTableId, + ) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("describe routing-tables: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, response) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + routingTableId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + RoutingTableId: routingTableId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, routingTable *iaas.RoutingTable) error { + if routingTable == nil { + return fmt.Errorf("describe routingtable response is empty") + } + + return p.OutputResult(outputFormat, routingTable, func() error { + table := tables.NewTable() + + table.AddRow("ID", utils.PtrString(routingTable.Id)) + table.AddSeparator() + + table.AddRow("NAME", utils.PtrString(routingTable.Name)) + table.AddSeparator() + + table.AddRow("DESCRIPTION", utils.PtrString(routingTable.Description)) + table.AddSeparator() + + table.AddRow("DEFAULT", utils.PtrString(routingTable.Default)) + table.AddSeparator() + + if routingTable.Labels != nil && len(*routingTable.Labels) > 0 { + var labels []string + for key, value := range *routingTable.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + table.AddRow("SYSTEM ROUTES", utils.PtrString(routingTable.SystemRoutes)) + table.AddSeparator() + + table.AddRow("DYNAMIC ROUTES", utils.PtrString(routingTable.DynamicRoutes)) + table.AddSeparator() + + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(routingTable.CreatedAt)) + table.AddSeparator() + + table.AddRow("UPDATED AT", utils.ConvertTimePToDateTimeString(routingTable.UpdatedAt)) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/network-area/routingtable/describe/describe_test.go b/internal/cmd/network-area/routingtable/describe/describe_test.go new file mode 100644 index 000000000..295b394a2 --- /dev/null +++ b/internal/cmd/network-area/routingtable/describe/describe_test.go @@ -0,0 +1,222 @@ +package describe + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + RoutingTableId: testRoutingTableId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRoutingTableId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + flagValues: fixtureFlagValues(), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing organization ID", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "invalid organization ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid organization ID - format", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing network area ID", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "invalid network area ID - empty", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid network area ID - format", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "missing routing-table ID", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid routing-table ID - format", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRouteTable := iaas.RoutingTable{ + CreatedAt: utils.Ptr(time.Now()), + Default: nil, + Description: utils.Ptr("description"), + Id: utils.Ptr("route-foo"), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: utils.Ptr("route-foo"), + SystemRoutes: utils.Ptr(true), + DynamicRoutes: utils.Ptr(true), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routingTable *iaas.RoutingTable + wantErr bool + }{ + { + name: "nil routing table", + outputFormat: print.PrettyOutputFormat, + routingTable: nil, + wantErr: true, + }, + { + name: "empty routing table", + outputFormat: print.PrettyOutputFormat, + routingTable: &iaas.RoutingTable{}, + wantErr: false, + }, + { + name: "json empty routing table", + outputFormat: print.JSONOutputFormat, + routingTable: &iaas.RoutingTable{}, + wantErr: false, + }, + { + name: "pretty output one route", + outputFormat: print.PrettyOutputFormat, + routingTable: &dummyRouteTable, + wantErr: false, + }, + { + name: "json output one route", + outputFormat: print.JSONOutputFormat, + routingTable: &dummyRouteTable, + wantErr: false, + }, + { + name: "yaml output one route", + outputFormat: print.YAMLOutputFormat, + routingTable: &dummyRouteTable, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.routingTable); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/routingtable/list/list.go b/internal/cmd/network-area/routingtable/list/list.go new file mode 100644 index 000000000..82553f83c --- /dev/null +++ b/internal/cmd/network-area/routingtable/list/list.go @@ -0,0 +1,176 @@ +package list + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + labelSelectorFlag = "label-selector" + limitFlag = "limit" + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LabelSelector *string + Limit *int64 + NetworkAreaId string + OrganizationId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all routing-tables", + Long: "Lists all routing-tables", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all routing-tables`, + `$ stackit network-area routing-table list --organization-id xxx --network-area-id yyy`, + ), + examples.NewExample( + `List all routing-tables with labels`, + `$ stackit network-area routing-table list --label-selector env=dev,env=rc --organization-id xxx --network-area-id yyy`, + ), + examples.NewExample( + `List all routing-tables with labels and set limit to 10`, + `$ stackit network-area routing-table list --label-selector env=dev,env=rc --limit 10 --organization-id xxx --network-area-id yyy`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := buildRequest(ctx, model, apiClient) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list routing-tables: %w", err) + } + + routingTables := utils.GetSliceFromPointer(response.Items) + + // Truncate output + if model.Limit != nil && len(routingTables) > int(*model.Limit) { + routingTables = routingTables[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, routingTables, model.OrganizationId) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + Limit: limit, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListRoutingTablesOfAreaRequest { + request := apiClient.ListRoutingTablesOfArea(ctx, model.OrganizationId, model.NetworkAreaId, model.Region) + if model.LabelSelector != nil { + request = request.LabelSelector(*model.LabelSelector) + } + + return request +} + +func outputResult(p *print.Printer, outputFormat string, routingTables []iaas.RoutingTable, orgId string) error { + if routingTables == nil { + return fmt.Errorf("list routing-table items are nil") + } + + return p.OutputResult(outputFormat, routingTables, func() error { + if len(routingTables) == 0 { + p.Outputf("No routing-tables found for organization %q\n", orgId) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "DESCRIPTION", "DEFAULT", "LABELS", "SYSTEM ROUTES", "DYNAMIC ROUTES", "CREATED AT", "UPDATED AT") + for _, routingTable := range routingTables { + var labels []string + if routingTable.Labels != nil && len(*routingTable.Labels) > 0 { + for key, value := range *routingTable.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + } + + table.AddRow( + utils.PtrString(routingTable.Id), + utils.PtrString(routingTable.Name), + utils.PtrString(routingTable.Description), + utils.PtrString(routingTable.Default), + strings.Join(labels, "\n"), + utils.PtrString(routingTable.SystemRoutes), + utils.PtrString(routingTable.DynamicRoutes), + utils.ConvertTimePToDateTimeString(routingTable.CreatedAt), + utils.ConvertTimePToDateTimeString(routingTable.UpdatedAt), + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/network-area/routingtable/list/list_test.go b/internal/cmd/network-area/routingtable/list/list_test.go new file mode 100644 index 000000000..3bccad11c --- /dev/null +++ b/internal/cmd/network-area/routingtable/list/list_test.go @@ -0,0 +1,281 @@ +package list + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() + +const testLabelSelectorFlag = "key1=value1,key2=value2" + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testLimitFlag = int64(10) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + labelSelectorFlag: testLabelSelectorFlag, + limitFlag: strconv.Itoa(int(testLimitFlag)), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureRequest(mods ...func(request *iaas.ApiListRoutingTablesOfAreaRequest)) iaas.ApiListRoutingTablesOfAreaRequest { + request := testClient.ListRoutingTablesOfArea(testCtx, testOrgId, testNetworkAreaId, testRegion) + request = request.LabelSelector(testLabelSelectorFlag) + + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + LabelSelector: utils.Ptr(testLabelSelectorFlag), + Limit: utils.Ptr(testLimitFlag), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "missing network area ID", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "missing organization ID", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "missing labels", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + }, + { + description: "missing limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + description: "invalid limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "negative limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-10" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRouteTable := iaas.RoutingTable{ + CreatedAt: utils.Ptr(time.Now()), + Default: nil, + Description: utils.Ptr("description"), + Id: utils.Ptr("route-foo"), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: utils.Ptr("route-foo"), + SystemRoutes: utils.Ptr(true), + DynamicRoutes: utils.Ptr(true), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routingTable []iaas.RoutingTable + wantErr bool + }{ + { + name: "nil routing table", + outputFormat: print.PrettyOutputFormat, + routingTable: nil, + wantErr: true, + }, + { + name: "pretty empty routing table", + outputFormat: print.PrettyOutputFormat, + routingTable: []iaas.RoutingTable{}, + wantErr: false, + }, + { + name: "json empty routing table", + outputFormat: print.JSONOutputFormat, + routingTable: []iaas.RoutingTable{}, + wantErr: false, + }, + { + name: "yaml empty routing table", + outputFormat: print.YAMLOutputFormat, + routingTable: []iaas.RoutingTable{}, + wantErr: false, + }, + { + name: "pretty empty routing table in slice", + outputFormat: print.PrettyOutputFormat, + routingTable: []iaas.RoutingTable{{}}, + wantErr: false, + }, + { + name: "yaml empty routing table in slice", + outputFormat: print.YAMLOutputFormat, + routingTable: []iaas.RoutingTable{{}}, + wantErr: false, + }, + { + name: "pretty output with one route", + outputFormat: print.PrettyOutputFormat, + routingTable: []iaas.RoutingTable{dummyRouteTable}, + wantErr: false, + }, + { + name: "pretty output with multiple routes", + outputFormat: print.PrettyOutputFormat, + routingTable: []iaas.RoutingTable{dummyRouteTable, dummyRouteTable, dummyRouteTable}, + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routingTable: []iaas.RoutingTable{dummyRouteTable}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routingTable: []iaas.RoutingTable{dummyRouteTable}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.routingTable, "dummy-org-id"); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListRoutingTablesOfAreaRequest + }{ + { + description: "valid input with label selector", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "missing label selector", + model: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiListRoutingTablesOfAreaRequest) { + *request = testClient.ListRoutingTablesOfArea(testCtx, testOrgId, testNetworkAreaId, testRegion) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + if diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx)); diff != "" { + t.Errorf("buildRequest() mismatch (-got +want):\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/network-area/routingtable/route/create/create.go b/internal/cmd/network-area/routingtable/route/create/create.go new file mode 100644 index 000000000..d3455087d --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/create/create.go @@ -0,0 +1,269 @@ +package create + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + destinationTypeFlag = "destination-type" + destinationValueFlag = "destination-value" + labelFlag = "labels" + networkAreaIdFlag = "network-area-id" + nextHopTypeFlag = "nexthop-type" + nextHopValueFlag = "nexthop-value" + organizationIdFlag = "organization-id" + routingTableIdFlag = "routing-table-id" + + // Destination Type Constants + destTypeCIDRv4 = "cidrv4" + destTypeCIDRv6 = "cidrv6" + + // NextHop Type Constants + nextHopTypeIPv4 = "ipv4" + nextHopTypeIPv6 = "ipv6" + nextHopTypeInternet = "internet" + nextHopTypeBlackhole = "blackhole" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + DestinationType string + DestinationValue *string + Labels *map[string]string + NetworkAreaId string + NextHopType string + NextHopValue *string + OrganizationId string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a route in a routing-table", + Long: "Creates a route in a routing-table.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample("Create a route with CIDRv4 destination and IPv4 nexthop", + `$ stackit network-area routing-table route create --routing-table-id xxx --organization-id yyy --network-area-id zzz --destination-type cidrv4 --destination-value --nexthop-type ipv4 --nexthop-value `), + + examples.NewExample("Create a route with CIDRv6 destination and IPv6 nexthop", + `$ stackit network-area routing-table route create --routing-table-id xxx --organization-id yyy --network-area-id zzz --destination-type cidrv6 --destination-value --nexthop-type ipv6 --nexthop-value `), + + examples.NewExample("Create a route with CIDRv6 destination and Nexthop Internet", + `$ stackit network-area routing-table route create --routing-table-id xxx --organization-id yyy --network-area-id zzz --destination-type cidrv6 --destination-value --nexthop-type internet`), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + routingTableLabel, err := iaasUtils.GetRoutingTableOfAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region, model.RoutingTableId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get routing-table name: %v", err) + routingTableLabel = model.RoutingTableId + } else if routingTableLabel == "" { + routingTableLabel = model.RoutingTableId + } + + prompt := fmt.Sprintf("Are you sure you want to create a route for routing-table %q?", routingTableLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create route request failed: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp.GetItems()) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.CIDRFlag(), destinationValueFlag, "Destination value") + cmd.Flags().String(nextHopValueFlag, "", "NextHop value") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + cmd.Flags().Var( + flags.EnumFlag(true, "", destTypeCIDRv4, destTypeCIDRv6), + destinationTypeFlag, + "Destination type") + + cmd.Flags().Var( + flags.EnumFlag(true, "", nextHopTypeIPv4, nextHopTypeIPv6, nextHopTypeInternet, nextHopTypeBlackhole), + nextHopTypeFlag, + "Next hop type") + + cmd.Flags().StringToString(labelFlag, nil, "Key=value labels") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag, destinationTypeFlag, destinationValueFlag, nextHopTypeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := &inputModel{ + GlobalFlagModel: globalFlags, + DestinationType: flags.FlagToStringValue(p, cmd, destinationTypeFlag), + DestinationValue: flags.FlagToStringPointer(p, cmd, destinationValueFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + NextHopType: flags.FlagToStringValue(p, cmd, nextHopTypeFlag), + NextHopValue: flags.FlagToStringPointer(p, cmd, nextHopValueFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + } + + // Next Hop validation logic + switch strings.ToLower(model.NextHopType) { + case nextHopTypeInternet, nextHopTypeBlackhole: + if model.NextHopValue != nil && *model.NextHopValue != "" { + return nil, errors.New("--nexthop-value is not allowed when --nexthop-type is 'internet' or 'blackhole'") + } + case nextHopTypeIPv4, nextHopTypeIPv6: + if model.NextHopValue == nil || *model.NextHopValue == "" { + return nil, errors.New("--nexthop-value is required when --nexthop-type is 'ipv4' or 'ipv6'") + } + default: + return nil, fmt.Errorf("invalid nexthop-type: %q", model.NextHopType) + } + + p.DebugInputModel(model) + return model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) (iaas.ApiAddRoutesToRoutingTableRequest, error) { + destination := buildDestination(model) + nextHop := buildNextHop(model) + + if destination != nil && nextHop != nil { + payload := iaas.AddRoutesToRoutingTablePayload{ + Items: &[]iaas.Route{ + { + Destination: destination, + Nexthop: nextHop, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + }, + }, + } + + return apiClient.AddRoutesToRoutingTable( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + model.RoutingTableId, + ).AddRoutesToRoutingTablePayload(payload), nil + } + + return nil, fmt.Errorf("invalid input") +} + +func buildDestination(model *inputModel) *iaas.RouteDestination { + if model.DestinationValue == nil { + return nil + } + + destinationType := strings.ToLower(model.DestinationType) + switch destinationType { + case destTypeCIDRv4: + return &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: &model.DestinationType, + Value: model.DestinationValue, + }, + } + case destTypeCIDRv6: + return &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: &model.DestinationType, + Value: model.DestinationValue, + }, + } + default: + return nil + } +} + +func buildNextHop(model *inputModel) *iaas.RouteNexthop { + nextHopType := strings.ToLower(model.NextHopType) + switch nextHopType { + case nextHopTypeIPv4: + return &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: &model.NextHopType, + Value: model.NextHopValue, + }, + } + case nextHopTypeIPv6: + return &iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: &model.NextHopType, + Value: model.NextHopValue, + }, + } + case nextHopTypeInternet: + return &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: &model.NextHopType, + }, + } + case nextHopTypeBlackhole: + return &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: &model.NextHopType, + }, + } + default: + return nil + } +} + +func outputResult(p *print.Printer, outputFormat string, routes []iaas.Route) error { + if len(routes) == 0 { + return fmt.Errorf("create routes response is empty") + } + + return p.OutputResult(outputFormat, routes, func() error { + for _, route := range routes { + p.Outputf("Created route with ID %q\n", utils.PtrString(route.Id)) + } + return nil + }) +} diff --git a/internal/cmd/network-area/routingtable/route/create/create_test.go b/internal/cmd/network-area/routingtable/route/create/create_test.go new file mode 100644 index 000000000..a5b99aff0 --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/create/create_test.go @@ -0,0 +1,702 @@ +package create + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +const testDestinationTypeFlag = destTypeCIDRv4 +const testDestinationValueFlag = "1.1.1.0/24" +const testNextHopTypeFlag = nextHopTypeIPv4 +const testNextHopValueFlag = "1.1.1.1" +const testLabelSelectorFlag = "key1=value1,key2=value2" + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + labelFlag: testLabelSelectorFlag, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + destinationTypeFlag: testDestinationTypeFlag, + destinationValueFlag: testDestinationValueFlag, + nextHopTypeFlag: testNextHopTypeFlag, + nextHopValueFlag: testNextHopValueFlag, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + RoutingTableId: testRoutingTableId, + DestinationType: testDestinationTypeFlag, + DestinationValue: utils.Ptr(testDestinationValueFlag), + NextHopType: testNextHopTypeFlag, + NextHopValue: utils.Ptr(testNextHopValueFlag), + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiAddRoutesToRoutingTableRequest)) iaas.ApiAddRoutesToRoutingTableRequest { + request := testClient.AddRoutesToRoutingTable(testCtx, testOrgId, testNetworkAreaId, testRegion, testRoutingTableId) + request = request.AddRoutesToRoutingTablePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *iaas.AddRoutesToRoutingTablePayload)) iaas.AddRoutesToRoutingTablePayload { + payload := iaas.AddRoutesToRoutingTablePayload{ + Items: &[]iaas.Route{ + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(testDestinationTypeFlag), + Value: utils.Ptr(testDestinationValueFlag), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(testNextHopTypeFlag), + Value: utils.Ptr(testNextHopValueFlag), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + }, + }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "routing-table ID missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "destination value missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, destinationValueFlag) + }), + isValid: false, + }, + { + description: "destination type missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, destinationTypeFlag) + }), + isValid: false, + }, + { + description: "next hop type missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nextHopTypeFlag) + }), + isValid: false, + }, + { + description: "next hop value missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + 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: "organization ID invalid - empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "" + }), + isValid: false, + }, + { + description: "organization ID invalid - format", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "network area ID missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "network area ID invalid - empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "" + }), + isValid: false, + }, + { + description: "network area ID invalid - format", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid destination type enum", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationTypeFlag] = nextHopTypeIPv4 // Deliberately invalid for dest + }), + isValid: false, + }, + { + description: "destination value not IPv4 CIDR", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationValueFlag] = "0.0.0.0" + }), + isValid: false, + }, + { + description: "destination value not IPv6 CIDR", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationTypeFlag] = destTypeCIDRv6 + flagValues[destinationValueFlag] = "2001:db8::" + }), + isValid: false, + }, + { + description: "destination value is IPv6 CIDR", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[destinationTypeFlag] = destTypeCIDRv6 + flagValues[destinationValueFlag] = "2001:db8::/32" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DestinationType = destTypeCIDRv6 + model.DestinationValue = utils.Ptr("2001:db8::/32") + }), + }, + { + description: "invalid next hop type enum", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = destTypeCIDRv4 // Deliberately invalid for hop + }), + isValid: false, + }, + { + description: "next hop type is internet and next hop value is provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = nextHopTypeInternet + flagValues[nextHopValueFlag] = "1.1.1.1" // should not be allowed + }), + isValid: false, + }, + { + description: "next hop type is blackhole and next hop value is provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = nextHopTypeBlackhole + flagValues[nextHopValueFlag] = "1.1.1.1" + }), + isValid: false, + }, + { + description: "next hop type is internet and next hop value is not provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = nextHopTypeInternet + delete(flagValues, nextHopValueFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeInternet + model.NextHopValue = nil + }), + isValid: true, + }, + { + description: "next hop type is blackhole and next hop value is not provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = nextHopTypeBlackhole + delete(flagValues, nextHopValueFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeBlackhole + model.NextHopValue = nil + }), + isValid: true, + }, + { + description: "next hop type is IPv4 and next hop value is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = nextHopTypeIPv4 + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "next hop type is IPv6 and next hop value is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = nextHopTypeIPv6 + delete(flagValues, nextHopValueFlag) + }), + isValid: false, + }, + { + description: "invalid next hop type provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nextHopTypeFlag] = "invalid-type" + }), + isValid: false, + }, + { + description: "optional labels are provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[labelFlag] = "key=value" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = utils.Ptr(map[string]string{"key": "value"}) + }), + isValid: true, + }, + { + description: "argument value is empty string", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "argument value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildNextHop(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected *iaas.RouteNexthop + }{ + { + description: "IPv4 next hop", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = nextHopTypeIPv4 + m.NextHopValue = utils.Ptr("1.1.1.1") + }), + expected: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(nextHopTypeIPv4), + Value: utils.Ptr("1.1.1.1"), + }, + }, + }, + { + description: "IPv6 next hop", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = nextHopTypeIPv6 + m.NextHopValue = utils.Ptr("::1") + }), + expected: &iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: utils.Ptr(nextHopTypeIPv6), + Value: utils.Ptr("::1"), + }, + }, + }, + { + description: "Internet next hop", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = nextHopTypeInternet + m.NextHopValue = nil + }), + expected: &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: utils.Ptr(nextHopTypeInternet), + }, + }, + }, + { + description: "Blackhole next hop", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = nextHopTypeBlackhole + m.NextHopValue = nil + }), + expected: &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr(nextHopTypeBlackhole), + }, + }, + }, + { + description: "Unsupported next hop type", + model: fixtureInputModel(func(m *inputModel) { + m.NextHopType = "unsupported" + }), + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildNextHop(tt.model) + if diff := cmp.Diff(tt.expected, got); diff != "" { + t.Errorf("buildNextHop() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestBuildDestination(t *testing.T) { + tests := []struct { + description string + model *inputModel + expected *iaas.RouteDestination + }{ + { + description: "CIDRv4 destination", + model: fixtureInputModel(func(m *inputModel) { + m.DestinationType = destTypeCIDRv4 + m.DestinationValue = utils.Ptr("192.168.1.0/24") + }), + expected: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(destTypeCIDRv4), + Value: utils.Ptr("192.168.1.0/24"), + }, + }, + }, + { + description: "CIDRv6 destination", + model: fixtureInputModel(func(m *inputModel) { + m.DestinationType = destTypeCIDRv6 + m.DestinationValue = utils.Ptr("2001:db8::/32") + }), + expected: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr(destTypeCIDRv6), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + }, + { + description: "unsupported destination type", + model: fixtureInputModel(func(m *inputModel) { + m.DestinationType = "other" + m.DestinationValue = utils.Ptr("1.1.1.1") + }), + expected: nil, + }, + { + description: "nil destination value", + model: fixtureInputModel(func(m *inputModel) { + m.DestinationValue = nil + }), + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildDestination(tt.model) + if diff := cmp.Diff(tt.expected, got); diff != "" { + t.Errorf("buildDestination() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiAddRoutesToRoutingTableRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "optional labels provided", + model: fixtureInputModel(func(model *inputModel) { + model.Labels = utils.Ptr(map[string]string{"key": "value"}) + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutesToRoutingTableRequest) { + *request = (*request).AddRoutesToRoutingTablePayload(fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(map[string]string{"key": "value"})) + })) + }), + }, + { + description: "destination is cidrv6 and nexthop is ipv6", + model: fixtureInputModel(func(model *inputModel) { + model.DestinationType = destTypeCIDRv6 + model.DestinationValue = utils.Ptr("2001:db8::/32") + model.NextHopType = nextHopTypeIPv6 + model.NextHopValue = utils.Ptr("2001:db8::1") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutesToRoutingTableRequest) { + *request = (*request).AddRoutesToRoutingTablePayload(iaas.AddRoutesToRoutingTablePayload{ + Items: &[]iaas.Route{ + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr(destTypeCIDRv6), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: utils.Ptr(nextHopTypeIPv6), + Value: utils.Ptr("2001:db8::1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + }, + }, + }) + }), + }, + { + description: "nexthop type is internet (no value)", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeInternet + model.NextHopValue = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: utils.Ptr(nextHopTypeInternet), + }, + } + }) + *request = (*request).AddRoutesToRoutingTablePayload(payload) + }), + }, + { + description: "nexthop type is blackhole (no value)", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeBlackhole + model.NextHopValue = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr(nextHopTypeBlackhole), + }, + } + }) + *request = (*request).AddRoutesToRoutingTablePayload(payload) + }), + }, + { + description: "nexthop type is ipv4 with value", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeIPv4 + model.NextHopValue = utils.Ptr("1.2.3.4") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(nextHopTypeIPv4), + Value: utils.Ptr("1.2.3.4"), + }, + } + }) + *request = (*request).AddRoutesToRoutingTablePayload(payload) + }), + }, + { + description: "nexthop type is ipv6 with value", + model: fixtureInputModel(func(model *inputModel) { + model.NextHopType = nextHopTypeIPv6 + model.NextHopValue = utils.Ptr("2001:db8::1") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiAddRoutesToRoutingTableRequest) { + payload := fixturePayload(func(payload *iaas.AddRoutesToRoutingTablePayload) { + (*payload.Items)[0].Nexthop = &iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: utils.Ptr(nextHopTypeIPv6), + Value: utils.Ptr("2001:db8::1"), + }, + } + }) + *request = (*request).AddRoutesToRoutingTablePayload(payload) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("buildRequest returned error: %v", err) + } + + if diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx)); diff != "" { + t.Errorf("buildRequest() mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaas.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(destTypeCIDRv4), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(nextHopTypeIPv4), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routes []iaas.Route + wantErr bool + }{ + { + name: "nil routes should return error", + outputFormat: print.PrettyOutputFormat, + routes: nil, + wantErr: true, + }, + { + name: "empty routes list", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{}, + wantErr: true, + }, + { + name: "route list with empty struct", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{{}}, + wantErr: false, + }, + { + name: "pretty output with one route", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{dummyRoute}, + wantErr: false, + }, + { + name: "pretty output with multiple routes", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{dummyRoute, dummyRoute, dummyRoute}, + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routes: []iaas.Route{dummyRoute}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routes: []iaas.Route{dummyRoute}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.routes); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/routingtable/route/delete/delete.go b/internal/cmd/network-area/routingtable/route/delete/delete.go new file mode 100644 index 000000000..ba4ea28cb --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/delete/delete.go @@ -0,0 +1,121 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routeIdArg = "ROUTE_ID" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NetworkAreaId string + OrganizationId string + RouteID string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", routingTableIdFlag), + Short: "Deletes a route within a routing-table", + Long: "Deletes a route within a routing-table", + Args: args.SingleArg(routeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Deletes a route within a routing-table`, + `$ stackit network-area routing-table route delete xxx --routing-table-id xxx --organization-id yyy --network-area-id zzz`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + routingTableLabel, err := iaasUtils.GetRoutingTableOfAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region, model.RoutingTableId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get routing-table name: %v", err) + routingTableLabel = model.RoutingTableId + } else if routingTableLabel == "" { + routingTableLabel = model.RoutingTableId + } + + prompt := fmt.Sprintf("Are you sure you want to delete the route %q in routing-table %q for network area id %q?", model.RouteID, routingTableLabel, model.NetworkAreaId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := apiClient.DeleteRouteFromRoutingTable( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + model.RoutingTableId, + model.RouteID, + ) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete route from routing-table: %w", err) + } + + params.Printer.Outputf("Route %q from routing-table %q deleted.\n", model.RouteID, model.RoutingTableId) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + routeId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + RouteID: routeId, + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} diff --git a/internal/cmd/network-area/routingtable/route/delete/delete_test.go b/internal/cmd/network-area/routingtable/route/delete/delete_test.go new file mode 100644 index 000000000..eb956ef98 --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/delete/delete_test.go @@ -0,0 +1,133 @@ +package delete + +import ( + "testing" + + "github.com/google/uuid" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +var ( + testOrgId = uuid.NewString() + testNetworkAreaId = uuid.NewString() + testRoutingTableId = uuid.NewString() + testRouteId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.InfoVerbosity, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + RoutingTableId: testRoutingTableId, + RouteID: testRouteId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid input", + argValues: []string{testRouteId}, + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(func(m *inputModel) { + m.RouteID = testRouteId + }), + }, + { + description: "missing route id arg", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "missing organization-id flag", + argValues: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "organization-id") + }), + isValid: false, + }, + { + description: "missing network-area-id flag", + argValues: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "network-area-id") + }), + isValid: false, + }, + { + description: "missing routing-table-id flag", + argValues: []string{testRouteId}, + flagValues: fixtureFlagValues(func(m map[string]string) { + delete(m, "routing-table-id") + }), + isValid: false, + }, + { + description: "arg value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "invalid organization-id flag", + argValues: []string{testRouteId}, + flagValues: map[string]string{"organization-id": "invalid-org"}, + isValid: false, + }, + { + description: "invalid network-area-id flag", + argValues: []string{testRouteId}, + flagValues: map[string]string{"network-area-id": "invalid-area"}, + isValid: false, + }, + { + description: "invalid routing-table-id flag", + argValues: []string{testRouteId}, + flagValues: map[string]string{"routing-table-id": "invalid-table"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} diff --git a/internal/cmd/network-area/routingtable/route/describe/describe.go b/internal/cmd/network-area/routingtable/route/describe/describe.go new file mode 100644 index 000000000..128394538 --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/describe/describe.go @@ -0,0 +1,158 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + routeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/network-area/routing-table/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routeIdArg = "ROUTE_ID" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + NetworkAreaId string + OrganizationId string + RouteID string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", routeIdArg), + Short: "Describes a route within a routing-table", + Long: "Describes a route within a routing-table", + Args: args.SingleArg(routeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Describe a route within a routing-table`, + `$ stackit network-area routing-table route describe xxx --routing-table-id xxx --organization-id yyy --network-area-id zzz`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := apiClient.GetRouteOfRoutingTable( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + model.RoutingTableId, + model.RouteID, + ) + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("describe route: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, response) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + routeId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + RouteID: routeId, + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, route *iaas.Route) error { + if route == nil { + return fmt.Errorf("describe route response is empty") + } + + return p.OutputResult(outputFormat, route, func() error { + routeDetails := routeUtils.ExtractRouteDetails(*route) + + table := tables.NewTable() + + table.AddRow("ID", utils.PtrString(route.Id)) + table.AddSeparator() + + table.AddRow("DESTINATION TYPE", routeDetails.DestType) + table.AddSeparator() + + table.AddRow("DESTINATION VALUE", routeDetails.DestValue) + table.AddSeparator() + + table.AddRow("NEXTHOP TYPE", routeDetails.HopType) + table.AddSeparator() + + table.AddRow("NEXTHOP VALUE", routeDetails.HopValue) + table.AddSeparator() + + if route.Labels != nil && len(*route.Labels) > 0 { + var labels []string + for key, value := range *route.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + table.AddRow("CREATED AT", routeDetails.CreatedAt) + table.AddSeparator() + + table.AddRow("UPDATED AT", routeDetails.UpdatedAt) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/network-area/routingtable/route/describe/describe_test.go b/internal/cmd/network-area/routingtable/route/describe/describe_test.go new file mode 100644 index 000000000..4459a2fb8 --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/describe/describe_test.go @@ -0,0 +1,234 @@ +package describe + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() +var testRouteId = uuid.NewString() + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + RoutingTableId: testRoutingTableId, + RouteID: testRouteId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRouteId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "network-area-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "invalid routing-table-id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdFlag] = "invalid-id" + }), + isValid: false, + }, + { + description: "arg value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "invalid organization-id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[organizationIdFlag] = "invalid-org" + }), + isValid: false, + }, + { + description: "invalid network-area-id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[networkAreaIdFlag] = "invalid-area" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaas.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + route *iaas.Route + wantErr bool + }{ + { + name: "nil route should return error", + outputFormat: print.PrettyOutputFormat, + route: nil, + wantErr: true, + }, + { + name: "empty route", + outputFormat: print.PrettyOutputFormat, + route: &iaas.Route{}, + wantErr: false, + }, + { + name: "json empty route", + outputFormat: print.JSONOutputFormat, + route: &iaas.Route{}, + wantErr: false, + }, + { + name: "pretty output with one route", + outputFormat: print.PrettyOutputFormat, + route: &dummyRoute, + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + route: &dummyRoute, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + route: &dummyRoute, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.route); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/routingtable/route/list/list.go b/internal/cmd/network-area/routingtable/route/list/list.go new file mode 100644 index 000000000..4fe9f1122 --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/list/list.go @@ -0,0 +1,173 @@ +package list + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + routeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/network-area/routing-table/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + labelSelectorFlag = "label-selector" + limitFlag = "limit" + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + LabelSelector *string + Limit *int64 + NetworkAreaId string + OrganizationId string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all routes within a routing-table", + Long: "Lists all routes within a routing-table", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all routes within a routing-table`, + `$ stackit network-area routing-table route list --routing-table-id xxx --organization-id yyy --network-area-id zzz`, + ), + examples.NewExample( + `List all routes within a routing-table with labels`, + `$ stackit network-area routing-table list --routing-table-id xxx --organization-id yyy --network-area-id zzz --label-selector env=dev,env=rc`, + ), + examples.NewExample( + `List all routes within a routing-tables with labels and limit to 10`, + `$ stackit network-area routing-table list --routing-table-id xxx --organization-id yyy --network-area-id zzz --label-selector env=dev,env=rc --limit 10`, + ), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, nil) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + request := apiClient.ListRoutesOfRoutingTable( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + model.RoutingTableId, + ) + + if model.LabelSelector != nil { + request.LabelSelector(*model.LabelSelector) + } + + response, err := request.Execute() + if err != nil { + return fmt.Errorf("list routes: %w", err) + } + + routes := utils.GetSliceFromPointer(response.Items) + + // Truncate output + if model.Limit != nil && len(routes) > int(*model.Limit) { + routes = routes[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, routes, model.OrganizationId, model.RoutingTableId) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().String(labelSelectorFlag, "", "Filter by label") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), + Limit: limit, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, routes []iaas.Route, orgId, routeTableId string) error { + if routes == nil { + return fmt.Errorf("list routes routes are nil") + } + + return p.OutputResult(outputFormat, routes, func() error { + if len(routes) == 0 { + p.Outputf("No routes found for routing-table %q in organization %q\n", routeTableId, orgId) + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "DESTINATION TYPE", "DESTINATION VALUE", "NEXTHOP TYPE", "NEXTHOP VALUE", "LABELS", "CREATED AT", "UPDATED AT") + for _, route := range routes { + routeDetails := routeUtils.ExtractRouteDetails(route) + table.AddRow( + utils.PtrString(route.Id), + routeDetails.DestType, + routeDetails.DestValue, + routeDetails.HopType, + routeDetails.HopValue, + routeDetails.Labels, + routeDetails.CreatedAt, + routeDetails.UpdatedAt, + ) + } + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/network-area/routingtable/route/list/list_test.go b/internal/cmd/network-area/routingtable/route/list/list_test.go new file mode 100644 index 000000000..18930a4a2 --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/list/list_test.go @@ -0,0 +1,247 @@ +package list + +import ( + "strconv" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +const testLabelSelectorFlag = "key1=value1,key2=value2" + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +var testLimitFlag = int64(10) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + labelSelectorFlag: testLabelSelectorFlag, + limitFlag: strconv.Itoa(int(testLimitFlag)), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + RoutingTableId: testRoutingTableId, + LabelSelector: utils.Ptr(testLabelSelectorFlag), + Limit: utils.Ptr(testLimitFlag), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "routing-table-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "network-area-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "labels missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + }, + { + description: "limit missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + }), + }, + { + description: "invalid limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "negative limit flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "-10" + }), + isValid: false, + }, + { + description: "limit zero flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaas.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routes []iaas.Route + wantErr bool + }{ + { + name: "nil routes should return error", + outputFormat: print.PrettyOutputFormat, + routes: nil, + wantErr: true, + }, + { + name: "empty routes list", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{}, + wantErr: false, + }, + { + name: "empty routes list json output", + outputFormat: print.JSONOutputFormat, + routes: []iaas.Route{}, + wantErr: false, + }, + { + name: "empty routes list json output", + outputFormat: print.YAMLOutputFormat, + routes: []iaas.Route{}, + wantErr: false, + }, + { + name: "route list with empty struct", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{{}}, + wantErr: false, + }, + { + name: "pretty output with one route", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{dummyRoute}, + wantErr: false, + }, + { + name: "pretty output with multiple routes", + outputFormat: print.PrettyOutputFormat, + routes: []iaas.Route{dummyRoute, dummyRoute, dummyRoute}, + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + routes: []iaas.Route{dummyRoute}, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + routes: []iaas.Route{dummyRoute}, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, tt.routes, "dummy-org", "dummy-route-table-id"); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/routingtable/route/route.go b/internal/cmd/network-area/routingtable/route/route.go new file mode 100644 index 000000000..7b2d05559 --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/route.go @@ -0,0 +1,34 @@ +package route + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/route/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/route/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/route/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/route/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/route/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "route", + Short: "Manages routes of a routing-table", + Long: "Manages routes of a routing-table", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) +} diff --git a/internal/cmd/network-area/routingtable/route/update/update.go b/internal/cmd/network-area/routingtable/route/update/update.go new file mode 100644 index 000000000..719043be2 --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/update/update.go @@ -0,0 +1,155 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + labelFlag = "labels" + networkAreaIdFlag = "network-area-id" + organizationIdFlag = "organization-id" + routeIdArg = "ROUTE_ID" + routingTableIdFlag = "routing-table-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Labels *map[string]string + NetworkAreaId string + OrganizationId string + RouteId string + RoutingTableId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", routeIdArg), + Short: "Updates a route in a routing-table", + Long: "Updates a route in a routing-table.", + Args: args.SingleArg(routeIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Updates the label(s) of a route with ID "xxx" in a routing-table ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit network-area routing-table route update xxx --labels key=value,foo=bar --routing-table-id xxx --organization-id yyy --network-area-id zzz", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + routingTableLabel, err := iaasUtils.GetRoutingTableOfAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region, model.RoutingTableId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get routing-table name: %v", err) + routingTableLabel = model.RoutingTableId + } else if routingTableLabel == "" { + routingTableLabel = model.RoutingTableId + } + + prompt := fmt.Sprintf("Are you sure you want to update route %q for routing-table %q?", model.RouteId, routingTableLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update route %q of routing-table %q : %w", model.RouteId, model.RoutingTableId, err) + } + + return outputResult(params.Printer, model.OutputFormat, model.RoutingTableId, model.NetworkAreaId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a route. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "Routing-Table ID") + + err := flags.MarkFlagsRequired(cmd, labelFlag, organizationIdFlag, networkAreaIdFlag, routingTableIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + routeId := inputArgs[0] + + labels := flags.FlagToStringToStringPointer(p, cmd, labelFlag) + + if labels == nil { + return nil, &cliErr.EmptyUpdateError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Labels: labels, + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + RouteId: routeId, + RoutingTableId: flags.FlagToStringValue(p, cmd, routingTableIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, routingTableId, networkAreaId string, route *iaas.Route) error { + return p.OutputResult(outputFormat, route, func() error { + if route == nil { + return fmt.Errorf("update route response is empty") + } + + if route.Id == nil || *route.Id == "" { + return fmt.Errorf("update route response has empty id") + } + + p.Outputf("Updated route %q for routing-table %q in network-area %q.\n", *route.Id, routingTableId, networkAreaId) + return nil + }) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateRouteOfRoutingTableRequest { + req := apiClient.UpdateRouteOfRoutingTable( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + model.RoutingTableId, + model.RouteId, + ) + + payload := iaas.UpdateRouteOfRoutingTablePayload{ + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + } + + return req.UpdateRouteOfRoutingTablePayload(payload) +} diff --git a/internal/cmd/network-area/routingtable/route/update/update_test.go b/internal/cmd/network-area/routingtable/route/update/update_test.go new file mode 100644 index 000000000..1e1e0a19b --- /dev/null +++ b/internal/cmd/network-area/routingtable/route/update/update_test.go @@ -0,0 +1,302 @@ +package update + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() +var testRouteId = uuid.NewString() + +const testLabelSelectorFlag = "key1=value1,key2=value2" + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + routingTableIdFlag: testRoutingTableId, + labelFlag: testLabelSelectorFlag, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + RoutingTableId: testRoutingTableId, + RouteId: testRouteId, + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRouteId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureRequest(mods ...func(req *iaas.ApiUpdateRouteOfRoutingTableRequest)) iaas.ApiUpdateRouteOfRoutingTableRequest { + req := testClient.UpdateRouteOfRoutingTable( + testCtx, + testOrgId, + testNetworkAreaId, + testRegion, + testRoutingTableId, + testRouteId, + ) + + payload := iaas.UpdateRouteOfRoutingTablePayload{ + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + } + + req = req.UpdateRouteOfRoutingTablePayload(payload) + + for _, mod := range mods { + mod(&req) + } + + return req +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "network-area-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "routing-table-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: false, + }, + { + description: "arg value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "labels are missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelFlag) + }), + isValid: false, + }, + { + description: "invalid label format", + argValues: []string{}, + flagValues: map[string]string{labelFlag: "invalid-label"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateRouteOfRoutingTableRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "labels nil", + model: fixtureInputModel(func(m *inputModel) { + m.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateRouteOfRoutingTableRequest) { + *request = (*request).UpdateRouteOfRoutingTablePayload(iaas.UpdateRouteOfRoutingTablePayload{ + Labels: nil, + }) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + gotReq := buildRequest(testCtx, tt.model, testClient) + + if diff := cmp.Diff( + tt.expectedRequest, + gotReq, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ); diff != "" { + t.Errorf("buildRequest() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoute := iaas.Route{ + Id: utils.Ptr("route-foo"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + route *iaas.Route + wantErr bool + }{ + { + name: "nil route should return error", + outputFormat: print.PrettyOutputFormat, + route: nil, + wantErr: true, + }, + { + name: "empty route", + outputFormat: print.PrettyOutputFormat, + route: &iaas.Route{}, + // should fail on pretty format + wantErr: true, + }, + { + name: "pretty output with one route", + outputFormat: print.PrettyOutputFormat, + route: &dummyRoute, + wantErr: false, + }, + { + name: "json output with one route", + outputFormat: print.JSONOutputFormat, + route: &dummyRoute, + wantErr: false, + }, + { + name: "yaml output with one route", + outputFormat: print.YAMLOutputFormat, + route: &dummyRoute, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, "", "", tt.route); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/routingtable/routingtable.go b/internal/cmd/network-area/routingtable/routingtable.go new file mode 100644 index 000000000..514d8146a --- /dev/null +++ b/internal/cmd/network-area/routingtable/routingtable.go @@ -0,0 +1,41 @@ +package routingtable + +import ( + "github.com/spf13/cobra" + + rtCreate "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/create" + rtDelete "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/delete" + rtDescribe "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/describe" + rtList "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/list" + rtRoute "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/route" + rtUpdate "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/routingtable/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "routing-table", + Short: "Manage routing-tables and its according routes", + Long: `Manage routing-tables and their associated routes. + +This API is currently available only to selected customers. +To request access, please contact your account manager or submit a support ticket.`, + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand( + rtCreate.NewCmd(params), + rtUpdate.NewCmd(params), + rtList.NewCmd(params), + rtDescribe.NewCmd(params), + rtDelete.NewCmd(params), + rtRoute.NewCmd(params), + ) +} diff --git a/internal/cmd/network-area/routingtable/update/update.go b/internal/cmd/network-area/routingtable/update/update.go new file mode 100644 index 000000000..0d01f5e81 --- /dev/null +++ b/internal/cmd/network-area/routingtable/update/update.go @@ -0,0 +1,180 @@ +package update + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + descriptionFlag = "description" + labelFlag = "labels" + nameFlag = "name" + networkAreaIdFlag = "network-area-id" + dynamicRoutesFlag = "dynamic-routes" + systemRoutesFlag = "system-routes" + organizationIdFlag = "organization-id" + routingTableIdArg = "ROUTING_TABLE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string + NetworkAreaId string + DynamicRoutes *bool + SystemRoutes *bool + RoutingTableId string + Description *string + Labels *map[string]string + Name *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", routingTableIdArg), + Short: "Updates a routing-table", + Long: "Updates a routing-table.", + Args: args.SingleArg(routingTableIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Updates the label(s) of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit network-area routing-table update xxx --labels key=value,foo=bar --organization-id yyy --network-area-id zzz", + ), + examples.NewExample( + `Updates the name of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit network-area routing-table update xxx --name foo --organization-id yyy --network-area-id zzz", + ), + examples.NewExample( + `Updates the description of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit network-area routing-table update xxx --description foo --organization-id yyy --network-area-id zzz", + ), + examples.NewExample( + `Disables the dynamic routes of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit network-area routing-table update xxx --organization-id yyy --network-area-id zzz --dynamic-routes=false", + ), + examples.NewExample( + `Disables the system routes of a routing-table with ID "xxx" in organization with ID "yyy" and network-area with ID "zzz"`, + "$ stackit network-area routing-table update xxx --organization-id yyy --network-area-id zzz --system-routes=false", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + routingTableLabel, err := iaasUtils.GetRoutingTableOfAreaName(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region, model.RoutingTableId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get routing-table name: %v", err) + routingTableLabel = model.RoutingTableId + } else if routingTableLabel == "" { + routingTableLabel = model.RoutingTableId + } + + prompt := fmt.Sprintf("Are you sure you want to update the routing-table %q?", routingTableLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update routing-table %q : %w", model.RoutingTableId, err) + } + + return outputResult(params.Printer, model.OutputFormat, model.NetworkAreaId, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(descriptionFlag, "", "Description of the routing-table") + cmd.Flags().String(nameFlag, "", "Name of the routing-table") + cmd.Flags().StringToString(labelFlag, nil, "Key=value labels") + cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "Network-Area ID") + cmd.Flags().Bool(dynamicRoutesFlag, false, "If set to false, prevents dynamic routes from propagating to the routing table.") + cmd.Flags().Bool(systemRoutesFlag, false, "If set to false, disables routes for project-to-project communication.") + cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag) + cmd.MarkFlagsOneRequired(dynamicRoutesFlag, systemRoutesFlag, nameFlag, descriptionFlag, labelFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + routeTableId := inputArgs[0] + + model := inputModel{ + GlobalFlagModel: globalFlags, + Description: flags.FlagToStringPointer(p, cmd, descriptionFlag), + Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), + Name: flags.FlagToStringPointer(p, cmd, nameFlag), + NetworkAreaId: flags.FlagToStringValue(p, cmd, networkAreaIdFlag), + SystemRoutes: flags.FlagToBoolPointer(p, cmd, systemRoutesFlag), + DynamicRoutes: flags.FlagToBoolPointer(p, cmd, dynamicRoutesFlag), + OrganizationId: flags.FlagToStringValue(p, cmd, organizationIdFlag), + RoutingTableId: routeTableId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat, networkAreaId string, routingTable *iaas.RoutingTable) error { + return p.OutputResult(outputFormat, routingTable, func() error { + if routingTable == nil { + return fmt.Errorf("update routing-table response is empty") + } + + if routingTable.Id == nil { + return fmt.Errorf("update routing-table id is empty") + } + + p.Outputf("Updated routing-table %q in network-area %q.", *routingTable.Id, networkAreaId) + return nil + }) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateRoutingTableOfAreaRequest { + req := apiClient.UpdateRoutingTableOfArea( + ctx, + model.OrganizationId, + model.NetworkAreaId, + model.Region, + model.RoutingTableId, + ) + + payload := iaas.UpdateRoutingTableOfAreaPayload{ + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + Name: model.Name, + Description: model.Description, + DynamicRoutes: model.DynamicRoutes, + SystemRoutes: model.SystemRoutes, + } + + return req.UpdateRoutingTableOfAreaPayload(payload) +} diff --git a/internal/cmd/network-area/routingtable/update/update_test.go b/internal/cmd/network-area/routingtable/update/update_test.go new file mode 100644 index 000000000..790782124 --- /dev/null +++ b/internal/cmd/network-area/routingtable/update/update_test.go @@ -0,0 +1,415 @@ +package update + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} + +const testRegion = "eu01" + +var testOrgId = uuid.NewString() +var testNetworkAreaId = uuid.NewString() +var testRoutingTableId = uuid.NewString() + +const testRoutingTableName = "test" +const testRoutingTableDescription = "test" +const testLabelSelectorFlag = "key1=value1,key2=value2" + +const testSystemRoutesFlag = true +const testDynamicRoutesFlag = true + +var testLabels = &map[string]string{ + "key1": "value1", + "key2": "value2", +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + organizationIdFlag: testOrgId, + networkAreaIdFlag: testNetworkAreaId, + descriptionFlag: testRoutingTableDescription, + nameFlag: testRoutingTableName, + systemRoutesFlag: strconv.FormatBool(testSystemRoutesFlag), + dynamicRoutesFlag: strconv.FormatBool(testDynamicRoutesFlag), + labelFlag: testLabelSelectorFlag, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + OrganizationId: testOrgId, + NetworkAreaId: testNetworkAreaId, + Name: utils.Ptr(testRoutingTableName), + Description: utils.Ptr(testRoutingTableDescription), + SystemRoutes: utils.Ptr(testSystemRoutesFlag), + DynamicRoutes: utils.Ptr(testDynamicRoutesFlag), + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRoutingTableId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateRoutingTableOfAreaRequest)) iaas.ApiUpdateRoutingTableOfAreaRequest { + req := testClient.UpdateRoutingTableOfArea( + testCtx, + testOrgId, + testNetworkAreaId, + testRegion, + testRoutingTableId, + ) + + payload := iaas.UpdateRoutingTableOfAreaPayload{ + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: utils.Ptr(testRoutingTableName), + Description: utils.Ptr(testRoutingTableDescription), + DynamicRoutes: utils.Ptr(true), + SystemRoutes: utils.Ptr(true), + } + + req = req.UpdateRoutingTableOfAreaPayload(payload) + + for _, mod := range mods { + mod(&req) + } + return req +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.RoutingTableId = testRoutingTableId + }), + }, + { + description: "dynamic routes disabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[dynamicRoutesFlag] = "false" + }), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DynamicRoutes = utils.Ptr(false) + model.RoutingTableId = testRoutingTableId + }), + }, + { + description: "system routes disabled", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[systemRoutesFlag] = "false" + }), + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SystemRoutes = utils.Ptr(false) + model.RoutingTableId = testRoutingTableId + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "network-area-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, networkAreaIdFlag) + }), + isValid: false, + }, + { + description: "org-id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "all required flags missing", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, dynamicRoutesFlag) + delete(flagValues, systemRoutesFlag) + delete(flagValues, nameFlag) + delete(flagValues, labelFlag) + delete(flagValues, descriptionFlag) + }), + isValid: false, + }, + { + description: "all except one required flag missing (description flag)", + argValues: []string{testRoutingTableId}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, dynamicRoutesFlag) + delete(flagValues, systemRoutesFlag) + delete(flagValues, nameFlag) + delete(flagValues, labelFlag) + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.RoutingTableId = testRoutingTableId + model.DynamicRoutes = nil + model.SystemRoutes = nil + model.Labels = nil + model.Name = nil + model.Description = utils.Ptr(testRoutingTableDescription) + }), + isValid: true, + }, + { + description: "arg value missing", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "arg value wrong", + argValues: []string{"foo-bar"}, + flagValues: fixtureFlagValues(), + isValid: false, + expectedModel: fixtureInputModel(), + }, + { + description: "labels are missing", + argValues: []string{}, + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelFlag) + }), + isValid: false, + }, + { + description: "invalid label format", + argValues: []string{}, + flagValues: map[string]string{labelFlag: "invalid-label"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateRoutingTableOfAreaRequest + }{ + { + description: "base", + model: fixtureInputModel(func(model *inputModel) { + model.RoutingTableId = testRoutingTableId + }), + expectedRequest: fixtureRequest(), + }, + { + description: "labels missing", + model: fixtureInputModel(func(model *inputModel) { + model.RoutingTableId = testRoutingTableId + model.Labels = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateRoutingTableOfAreaRequest) { + *request = (*request).UpdateRoutingTableOfAreaPayload(iaas.UpdateRoutingTableOfAreaPayload{ + Labels: nil, + Name: utils.Ptr(testRoutingTableName), + Description: utils.Ptr(testRoutingTableDescription), + DynamicRoutes: utils.Ptr(true), + SystemRoutes: utils.Ptr(true), + }) + }), + }, + { + description: "name missing", + model: fixtureInputModel(func(model *inputModel) { + model.RoutingTableId = testRoutingTableId + model.Name = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateRoutingTableOfAreaRequest) { + *request = (*request).UpdateRoutingTableOfAreaPayload(iaas.UpdateRoutingTableOfAreaPayload{ + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: nil, + Description: utils.Ptr(testRoutingTableDescription), + DynamicRoutes: utils.Ptr(true), + SystemRoutes: utils.Ptr(true), + }) + }), + }, + { + description: "description missing", + model: fixtureInputModel(func(model *inputModel) { + model.RoutingTableId = testRoutingTableId + model.Description = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateRoutingTableOfAreaRequest) { + *request = (*request).UpdateRoutingTableOfAreaPayload(iaas.UpdateRoutingTableOfAreaPayload{ + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: utils.Ptr(testRoutingTableName), + Description: nil, + DynamicRoutes: utils.Ptr(true), + SystemRoutes: utils.Ptr(true), + }) + }), + }, + { + description: "dynamic routes disabled", + model: fixtureInputModel(func(model *inputModel) { + model.RoutingTableId = testRoutingTableId + model.DynamicRoutes = utils.Ptr(false) + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateRoutingTableOfAreaRequest) { + *request = (*request).UpdateRoutingTableOfAreaPayload(iaas.UpdateRoutingTableOfAreaPayload{ + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: utils.Ptr(testRoutingTableName), + Description: utils.Ptr(testRoutingTableDescription), + DynamicRoutes: utils.Ptr(false), + SystemRoutes: utils.Ptr(true), + }) + }), + }, + { + description: "system routes disabled", + model: fixtureInputModel(func(model *inputModel) { + model.RoutingTableId = testRoutingTableId + model.DynamicRoutes = utils.Ptr(false) + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateRoutingTableOfAreaRequest) { + *request = (*request).UpdateRoutingTableOfAreaPayload(iaas.UpdateRoutingTableOfAreaPayload{ + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + Name: utils.Ptr(testRoutingTableName), + Description: utils.Ptr(testRoutingTableDescription), + SystemRoutes: utils.Ptr(true), + DynamicRoutes: utils.Ptr(false), + }) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + req := buildRequest(testCtx, tt.model, testClient) + + if diff := cmp.Diff(req, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ); diff != "" { + t.Errorf("buildRequest() mismatch (-got +want):\n%s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + dummyRoutingTable := iaas.RoutingTable{ + Id: utils.Ptr("id-foo"), + Name: utils.Ptr("route-table-foo"), + Description: utils.Ptr("description-foo"), + SystemRoutes: utils.Ptr(true), + DynamicRoutes: utils.Ptr(true), + Labels: utils.ConvertStringMapToInterfaceMap(testLabels), + CreatedAt: utils.Ptr(time.Now()), + UpdatedAt: utils.Ptr(time.Now()), + } + + tests := []struct { + name string + outputFormat string + routingTable *iaas.RoutingTable + wantErr bool + }{ + { + name: "nil routing-table should return error", + outputFormat: print.PrettyOutputFormat, + routingTable: nil, + wantErr: true, + }, + { + name: "empty routing-table", + outputFormat: print.PrettyOutputFormat, + routingTable: &iaas.RoutingTable{}, + wantErr: true, + }, + { + name: "pretty output routing-table", + outputFormat: print.PrettyOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + { + name: "json output routing-table", + outputFormat: print.JSONOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + { + name: "yaml output routing-table", + outputFormat: print.YAMLOutputFormat, + routingTable: &dummyRoutingTable, + wantErr: false, + }, + } + + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.outputFormat, "network-area-id", tt.routingTable); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/network-area/update/update.go b/internal/cmd/network-area/update/update.go index 3a0c4f6f7..08d1f825c 100644 --- a/internal/cmd/network-area/update/update.go +++ b/internal/cmd/network-area/update/update.go @@ -2,10 +2,14 @@ package update import ( "context" - "encoding/json" "fmt" + "os" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/goccy/go-yaml" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -15,7 +19,6 @@ import ( rmClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" rmUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -23,29 +26,46 @@ import ( const ( areaIdArg = "AREA_ID" - nameFlag = "name" - organizationIdFlag = "organization-id" - areaIdFlag = "area-id" - dnsNameServersFlag = "dns-name-servers" + nameFlag = "name" + organizationIdFlag = "organization-id" + areaIdFlag = "area-id" + // Deprecated: dnsNameServersFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + dnsNameServersFlag = "dns-name-servers" + // Deprecated: defaultPrefixLengthFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. defaultPrefixLengthFlag = "default-prefix-length" - maxPrefixLengthFlag = "max-prefix-length" - minPrefixLengthFlag = "min-prefix-length" - labelFlag = "labels" + // Deprecated: maxPrefixLengthFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + maxPrefixLengthFlag = "max-prefix-length" + // Deprecated: minPrefixLengthFlag is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + minPrefixLengthFlag = "min-prefix-length" + labelFlag = "labels" + + deprecationMessage = "Deprecated and will be removed after April 2026. Use instead the new command `$ stackit network-area region` to configure these options for a network area." ) +// NetworkAreaResponses is a workaround, to keep the two responses of the iaas v2 api together for the json and yaml output +// Should be removed when the deprecated flags are removed +type NetworkAreaResponses struct { + NetworkArea iaas.NetworkArea `json:"network_area"` + RegionalArea *iaas.RegionalArea `json:"regional_area"` +} + type inputModel struct { *globalflags.GlobalFlagModel - AreaId string - Name *string - OrganizationId *string - DnsNameServers *[]string + AreaId string + Name *string + OrganizationId *string + // Deprecated: DnsNameServers is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + DnsNameServers *[]string + // Deprecated: DefaultPrefixLength is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. DefaultPrefixLength *int64 - MaxPrefixLength *int64 - MinPrefixLength *int64 - Labels *map[string]string + // Deprecated: MaxPrefixLength is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + MaxPrefixLength *int64 + // Deprecated: MinPrefixLength is deprecated, because with iaas v2 the create endpoint for network area was separated, remove this after April 2026. + MinPrefixLength *int64 + Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", areaIdArg), Short: "Updates a STACKIT Network Area (SNA)", @@ -59,37 +79,35 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } var orgLabel string - rmApiClient, err := rmClient.ConfigureClient(p) + rmApiClient, err := rmClient.ConfigureClient(params.Printer, params.CliVersion) if err == nil { orgLabel, err = rmUtils.GetOrganizationName(ctx, rmApiClient, *model.OrganizationId) if err != nil { - p.Debug(print.ErrorLevel, "get organization name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err) orgLabel = *model.OrganizationId } else if orgLabel == "" { orgLabel = *model.OrganizationId } } else { - p.Debug(print.ErrorLevel, "configure resource manager client: %v", err) + params.Printer.Debug(print.ErrorLevel, "configure resource manager client: %v", err) } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update a network area for organization %q?", orgLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update a network area for organization %q?", orgLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -99,7 +117,26 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update network area: %w", err) } - return outputResult(p, model.OutputFormat, orgLabel, *resp) + if resp == nil || resp.Id == nil { + return fmt.Errorf("update network area: empty response") + } + + responses := NetworkAreaResponses{ + NetworkArea: *resp, + } + + if hasDeprecatedFlagsSet(model) { + deprecatedFlags := getConfiguredDeprecatedFlags(model) + params.Printer.Warn("the flags %q are deprecated and will be removed after April 2026. Use `$ stackit network-area region` to configure these options for a network area.\n", strings.Join(deprecatedFlags, ",")) + reqNetworkArea := buildRequestNetworkAreaRegion(ctx, model, apiClient) + respNetworkArea, err := reqNetworkArea.Execute() + if err != nil { + return fmt.Errorf("create network area region: %w", err) + } + responses.RegionalArea = respNetworkArea + } + + return outputResult(params.Printer, model.OutputFormat, orgLabel, responses) }, } configureFlags(cmd) @@ -115,6 +152,13 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(minPrefixLengthFlag, 0, "The minimum prefix length for networks in the network area") cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network-area. E.g. '--labels key1=value1,key2=value2,...'") + cobra.CheckErr(cmd.Flags().MarkDeprecated(dnsNameServersFlag, deprecationMessage)) + cobra.CheckErr(cmd.Flags().MarkDeprecated(defaultPrefixLengthFlag, deprecationMessage)) + cobra.CheckErr(cmd.Flags().MarkDeprecated(maxPrefixLengthFlag, deprecationMessage)) + cobra.CheckErr(cmd.Flags().MarkDeprecated(minPrefixLengthFlag, deprecationMessage)) + // Set the output for deprecation warnings to stderr + cmd.Flags().SetOutput(os.Stderr) + err := flags.MarkFlagsRequired(cmd, organizationIdFlag) cobra.CheckErr(err) } @@ -136,66 +180,72 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiPartialUpdateNetworkAreaRequest { - req := apiClient.PartialUpdateNetworkArea(ctx, *model.OrganizationId, model.AreaId) +func hasDeprecatedFlagsSet(model *inputModel) bool { + deprecatedFlags := getConfiguredDeprecatedFlags(model) + return len(deprecatedFlags) > 0 +} - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } +func getConfiguredDeprecatedFlags(model *inputModel) []string { + var result []string + if model.DnsNameServers != nil { + result = append(result, dnsNameServersFlag) + } + if model.DefaultPrefixLength != nil { + result = append(result, defaultPrefixLengthFlag) + } + if model.MaxPrefixLength != nil { + result = append(result, maxPrefixLengthFlag) + } + if model.MinPrefixLength != nil { + result = append(result, minPrefixLengthFlag) } + return result +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiPartialUpdateNetworkAreaRequest { + req := apiClient.PartialUpdateNetworkArea(ctx, *model.OrganizationId, model.AreaId) payload := iaas.PartialUpdateNetworkAreaPayload{ Name: model.Name, - Labels: labelsMap, - AddressFamily: &iaas.UpdateAreaAddressFamily{ - Ipv4: &iaas.UpdateAreaIPv4{ - DefaultNameservers: model.DnsNameServers, - DefaultPrefixLen: model.DefaultPrefixLength, - MaxPrefixLen: model.MaxPrefixLength, - MinPrefixLen: model.MinPrefixLength, - }, - }, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } return req.PartialUpdateNetworkAreaPayload(payload) } -func outputResult(p *print.Printer, outputFormat, projectLabel string, networkArea iaas.NetworkArea) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(networkArea, "", " ") - if err != nil { - return fmt.Errorf("marshal network area: %w", err) - } - p.Outputln(string(details)) +func buildRequestNetworkAreaRegion(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateNetworkAreaRegionRequest { + req := apiClient.UpdateNetworkAreaRegion(ctx, *model.OrganizationId, model.AreaId, model.Region) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(networkArea, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network area: %w", err) - } - p.Outputln(string(details)) + payload := iaas.UpdateNetworkAreaRegionPayload{ + Ipv4: &iaas.UpdateRegionalAreaIPv4{ + DefaultNameservers: model.DnsNameServers, + DefaultPrefixLen: model.DefaultPrefixLength, + MaxPrefixLen: model.MaxPrefixLength, + MinPrefixLen: model.MinPrefixLength, + }, + } - return nil - default: + return req.UpdateNetworkAreaRegionPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, responses NetworkAreaResponses) error { + prettyOutputFunc := func() error { p.Outputf("Updated STACKIT Network Area for project %q.\n", projectLabel) return nil } + + // If RegionalArea is NOT set in the reponses, then no deprecated Flags were set. + // In this case, only the response of NetworkArea should be printed in JSON and yaml output, to avoid breaking changes after the deprecated fields are removed + if responses.RegionalArea == nil { + return p.OutputResult(outputFormat, responses.NetworkArea, prettyOutputFunc) + } + + return p.OutputResult(outputFormat, responses, func() error { + p.Outputf("Updated STACKIT Network Area for project %q.\n", projectLabel) + return nil + }) } diff --git a/internal/cmd/network-area/update/update_test.go b/internal/cmd/network-area/update/update_test.go index 807ec124f..e6963c929 100644 --- a/internal/cmd/network-area/update/update_test.go +++ b/internal/cmd/network-area/update/update_test.go @@ -2,8 +2,12 @@ package update import ( "context" + "strconv" + "strings" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,13 +18,25 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +const ( + testRegion = "eu01" + testName = "example-network-area-name" + testDefaultPrefixLength int64 = 25 + testMinPrefixLength int64 = 24 + testMaxPrefixLength int64 = 26 +) + type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &iaas.APIClient{} -var testOrgId = uuid.NewString() -var testAreaId = uuid.NewString() +var ( + testOrgId = uuid.NewString() + testAreaId = uuid.NewString() + + testDnsNameservers = []string{"1.1.1.0", "1.1.2.0"} +) func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -34,13 +50,11 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - nameFlag: "example-network-area-name", - organizationIdFlag: testOrgId, - dnsNameServersFlag: "1.1.1.0,1.1.2.0", - defaultPrefixLengthFlag: "24", - maxPrefixLengthFlag: "24", - minPrefixLengthFlag: "24", - labelFlag: "key=value", + globalflags.RegionFlag: testRegion, + + nameFlag: testName, + organizationIdFlag: testOrgId, + labelFlag: "key=value", } for _, mod := range mods { mod(flagValues) @@ -52,14 +66,11 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - Name: utils.Ptr("example-network-area-name"), - OrganizationId: utils.Ptr(testOrgId), - AreaId: testAreaId, - DnsNameServers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}), - DefaultPrefixLength: utils.Ptr(int64(24)), - MaxPrefixLength: utils.Ptr(int64(24)), - MinPrefixLength: utils.Ptr(int64(24)), + Name: utils.Ptr(testName), + OrganizationId: utils.Ptr(testOrgId), + AreaId: testAreaId, Labels: utils.Ptr(map[string]string{ "key": "value", }), @@ -81,17 +92,33 @@ func fixtureRequest(mods ...func(request *iaas.ApiPartialUpdateNetworkAreaReques func fixturePayload(mods ...func(payload *iaas.PartialUpdateNetworkAreaPayload)) iaas.PartialUpdateNetworkAreaPayload { payload := iaas.PartialUpdateNetworkAreaPayload{ - Name: utils.Ptr("example-network-area-name"), + Name: utils.Ptr(testName), Labels: utils.Ptr(map[string]interface{}{ "key": "value", }), - AddressFamily: &iaas.UpdateAreaAddressFamily{ - Ipv4: &iaas.UpdateAreaIPv4{ - DefaultNameservers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}), - DefaultPrefixLen: utils.Ptr(int64(24)), - MaxPrefixLen: utils.Ptr(int64(24)), - MinPrefixLen: utils.Ptr(int64(24)), - }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequestRegionalArea(mods ...func(request *iaas.ApiUpdateNetworkAreaRegionRequest)) iaas.ApiUpdateNetworkAreaRegionRequest { + request := testClient.UpdateNetworkAreaRegion(testCtx, testOrgId, testAreaId, testRegion) + request = request.UpdateNetworkAreaRegionPayload(fixturePayloadRegionalArea()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayloadRegionalArea(mods ...func(payload *iaas.UpdateNetworkAreaRegionPayload)) iaas.UpdateNetworkAreaRegionPayload { + payload := iaas.UpdateNetworkAreaRegionPayload{ + Ipv4: &iaas.UpdateRegionalAreaIPv4{ + DefaultNameservers: utils.Ptr(testDnsNameservers), + DefaultPrefixLen: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLen: utils.Ptr(testMaxPrefixLength), + MinPrefixLen: utils.Ptr(testMinPrefixLength), }, } for _, mod := range mods { @@ -117,20 +144,20 @@ func TestParseInput(t *testing.T) { expectedModel: fixtureInputModel(), }, { - description: "required only", + description: "with deprecated flags", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, dnsNameServersFlag) - delete(flagValues, defaultPrefixLengthFlag) - delete(flagValues, maxPrefixLengthFlag) - delete(flagValues, minPrefixLengthFlag) + flagValues[dnsNameServersFlag] = strings.Join(testDnsNameservers, ",") + flagValues[defaultPrefixLengthFlag] = strconv.FormatInt(testDefaultPrefixLength, 10) + flagValues[maxPrefixLengthFlag] = strconv.FormatInt(testMaxPrefixLength, 10) + flagValues[minPrefixLengthFlag] = strconv.FormatInt(testMinPrefixLength, 10) }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.DnsNameServers = nil - model.DefaultPrefixLength = nil - model.MaxPrefixLength = nil - model.MinPrefixLength = nil + model.DnsNameServers = utils.Ptr(testDnsNameservers) + model.DefaultPrefixLength = utils.Ptr(testDefaultPrefixLength) + model.MaxPrefixLength = utils.Ptr(testMaxPrefixLength) + model.MinPrefixLength = utils.Ptr(testMinPrefixLength) }), }, @@ -206,7 +233,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -285,11 +312,44 @@ func TestBuildRequest(t *testing.T) { } } +func TestBuildRequestNetworkAreaRegion(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateNetworkAreaRegionRequest + }{ + { + description: "base", + model: fixtureInputModel(func(model *inputModel) { + model.DnsNameServers = utils.Ptr(testDnsNameservers) + model.DefaultPrefixLength = utils.Ptr(testDefaultPrefixLength) + model.MaxPrefixLength = utils.Ptr(testMaxPrefixLength) + model.MinPrefixLength = utils.Ptr(testMinPrefixLength) + }), + expectedRequest: fixtureRequestRegionalArea(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequestNetworkAreaRegion(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) + } + }) + } +} + func TestOutputResult(t *testing.T) { type args struct { outputFormat string projectLabel string - networkArea iaas.NetworkArea + responses NetworkAreaResponses } tests := []struct { name string @@ -304,18 +364,144 @@ func TestOutputResult(t *testing.T) { { name: "empty network area", args: args{ - networkArea: iaas.NetworkArea{}, + responses: NetworkAreaResponses{ + NetworkArea: iaas.NetworkArea{}, + RegionalArea: nil, + }, }, wantErr: false, }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.networkArea); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.responses); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) } } + +func TestGetConfiguredDeprecatedFlags(t *testing.T) { + type args struct { + model *inputModel + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "no deprecated flags", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: utils.Ptr(testOrgId), + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + DnsNameServers: nil, + DefaultPrefixLength: nil, + MaxPrefixLength: nil, + MinPrefixLength: nil, + }, + }, + want: nil, + }, + { + name: "deprecated flags", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: utils.Ptr(testOrgId), + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + DnsNameServers: utils.Ptr(testDnsNameservers), + DefaultPrefixLength: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLength: utils.Ptr(testMaxPrefixLength), + MinPrefixLength: utils.Ptr(testMinPrefixLength), + }, + }, + want: []string{dnsNameServersFlag, defaultPrefixLengthFlag, minPrefixLengthFlag, maxPrefixLengthFlag}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getConfiguredDeprecatedFlags(tt.args.model) + + less := func(a, b string) bool { + return a < b + } + if diff := cmp.Diff(tt.want, got, cmpopts.SortSlices(less)); diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestHasDeprecatedFlagsSet(t *testing.T) { + type args struct { + model *inputModel + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "no deprecated flags", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: utils.Ptr(testOrgId), + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + DnsNameServers: nil, + DefaultPrefixLength: nil, + MaxPrefixLength: nil, + MinPrefixLength: nil, + }, + }, + want: false, + }, + { + name: "deprecated flags", + args: args{ + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Name: utils.Ptr(testName), + OrganizationId: utils.Ptr(testOrgId), + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + DnsNameServers: utils.Ptr(testDnsNameservers), + DefaultPrefixLength: utils.Ptr(testDefaultPrefixLength), + MaxPrefixLength: utils.Ptr(testMaxPrefixLength), + MinPrefixLength: utils.Ptr(testMinPrefixLength), + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasDeprecatedFlagsSet(tt.args.model); got != tt.want { + t.Errorf("hasDeprecatedFlagsSet() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cmd/network-interface/create/create.go b/internal/cmd/network-interface/create/create.go index a14e57b1e..5a1f5b8c3 100644 --- a/internal/cmd/network-interface/create/create.go +++ b/internal/cmd/network-interface/create/create.go @@ -2,12 +2,14 @@ package create import ( "context" - "encoding/json" "fmt" "regexp" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -39,7 +40,7 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - NetworkId *string + NetworkId string AllowedAddresses *[]iaas.AllowedAddressesInner Ipv4 *string Ipv6 *string @@ -49,7 +50,7 @@ type inputModel struct { SecurityGroups *[]string // = 36 characters + regex ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a network interface", @@ -65,33 +66,31 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit network-interface create --network-id xxx --allowed-addresses "1.1.1.1,8.8.8.8,9.9.9.9" --labels key=value,key2=value2 --name NAME --security-groups "UUID1,UUID2" --nic-security`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a network interface for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a network interface for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -101,7 +100,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create network interface: %w", err) } - return outputResult(p, model.OutputFormat, model.ProjectId, resp) + return outputResult(params.Printer, model.OutputFormat, model.ProjectId, resp) }, } configureFlags(cmd) @@ -122,7 +121,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -178,7 +177,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { model := inputModel{ GlobalFlagModel: globalFlags, - NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag), + NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag), Ipv4: flags.FlagToStringPointer(p, cmd, ipv4Flag), Ipv6: flags.FlagToStringPointer(p, cmd, ipv6Flag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), @@ -191,36 +190,18 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { model.AllowedAddresses = utils.Ptr(allowedAddressesInner) } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateNicRequest { - req := apiClient.CreateNic(ctx, model.ProjectId, *model.NetworkId) - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - convertedMap := make(map[string]interface{}, len(*model.Labels)) - for k, v := range *model.Labels { - convertedMap[k] = v - } - labelsMap = &convertedMap - } + req := apiClient.CreateNic(ctx, model.ProjectId, model.Region, model.NetworkId) payload := iaas.CreateNicPayload{ AllowedAddresses: model.AllowedAddresses, Ipv4: model.Ipv4, Ipv6: model.Ipv6, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), Name: model.Name, NicSecurity: model.NicSecurity, SecurityGroups: model.SecurityGroups, @@ -232,25 +213,8 @@ func outputResult(p *print.Printer, outputFormat, projectId string, nic *iaas.NI if nic == nil { return fmt.Errorf("nic is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(nic, "", " ") - if err != nil { - return fmt.Errorf("marshal network interface: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(nic, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network interface: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, nic, func() error { p.Outputf("Created network interface for project %q.\nNIC ID: %s\n", projectId, utils.PtrString(nic.Id)) return nil - } + }) } diff --git a/internal/cmd/network-interface/create/create_test.go b/internal/cmd/network-interface/create/create_test.go index 843b15f69..50dd643d0 100644 --- a/internal/cmd/network-interface/create/create_test.go +++ b/internal/cmd/network-interface/create/create_test.go @@ -4,13 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + testRegion = "eu01" ) type testCtxKey struct{} @@ -18,14 +26,15 @@ type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &iaas.APIClient{} -var projectIdFlag = globalflags.ProjectIdFlag var testProjectId = uuid.NewString() var testNetworkId = uuid.NewString() var testSecurityGroup = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + networkIdFlag: testNetworkId, allowedAddressesFlag: "1.1.1.1,8.8.8.8,9.9.9.9", ipv4Flag: "1.2.3.4", @@ -42,7 +51,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st } func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { - var allowedAddresses []iaas.AllowedAddressesInner = []iaas.AllowedAddressesInner{ + var allowedAddresses = []iaas.AllowedAddressesInner{ iaas.StringAsAllowedAddressesInner(utils.Ptr("1.1.1.1")), iaas.StringAsAllowedAddressesInner(utils.Ptr("8.8.8.8")), iaas.StringAsAllowedAddressesInner(utils.Ptr("9.9.9.9")), @@ -50,9 +59,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, - NetworkId: utils.Ptr(testNetworkId), + NetworkId: testNetworkId, AllowedAddresses: utils.Ptr(allowedAddresses), Ipv4: utils.Ptr("1.2.3.4"), Ipv6: utils.Ptr("2001:0db8:85a3:08d3::0370:7344"), @@ -70,7 +80,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiCreateNicRequest)) iaas.ApiCreateNicRequest { - request := testClient.CreateNic(testCtx, testProjectId, testNetworkId) + request := testClient.CreateNic(testCtx, testProjectId, testRegion, testNetworkId) request = request.CreateNicPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -79,7 +89,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateNicRequest)) iaas.ApiCre } func fixturePayload(mods ...func(payload *iaas.CreateNicPayload)) iaas.CreateNicPayload { - var allowedAddresses []iaas.AllowedAddressesInner = []iaas.AllowedAddressesInner{ + var allowedAddresses = []iaas.AllowedAddressesInner{ iaas.StringAsAllowedAddressesInner(utils.Ptr("1.1.1.1")), iaas.StringAsAllowedAddressesInner(utils.Ptr("8.8.8.8")), iaas.StringAsAllowedAddressesInner(utils.Ptr("9.9.9.9")), @@ -104,6 +114,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateNicPayload)) iaas.CreateNic func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -189,46 +200,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -286,7 +258,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.projectId, tt.args.nic); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-interface/delete/delete.go b/internal/cmd/network-interface/delete/delete.go index 4f2cd3302..af8a49eee 100644 --- a/internal/cmd/network-interface/delete/delete.go +++ b/internal/cmd/network-interface/delete/delete.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -12,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -23,11 +26,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - NetworkId *string + NetworkId string NicId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", nicIdArg), Short: "Deletes a network interface", @@ -41,23 +44,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete the network interface %q? (This cannot be undone)", model.NicId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete the network interface %q? (This cannot be undone)", model.NicId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -67,7 +68,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete network interface: %w", err) } - p.Info("Deleted network interface %q\n", model.NicId) + params.Printer.Info("Deleted network interface %q\n", model.NicId) return nil }, @@ -89,23 +90,15 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, - NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag), + NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag), NicId: nicId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteNicRequest { - req := apiClient.DeleteNic(ctx, model.ProjectId, *model.NetworkId, model.NicId) + req := apiClient.DeleteNic(ctx, model.ProjectId, model.Region, model.NetworkId, model.NicId) return req } diff --git a/internal/cmd/network-interface/delete/delete_test.go b/internal/cmd/network-interface/delete/delete_test.go index 4b9fdd56b..2f50c9511 100644 --- a/internal/cmd/network-interface/delete/delete_test.go +++ b/internal/cmd/network-interface/delete/delete_test.go @@ -4,13 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + testRegion = "eu01" ) type testCtxKey struct{} @@ -18,7 +24,6 @@ type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &iaas.APIClient{} -var projectIdFlag = globalflags.ProjectIdFlag var testProjectId = uuid.NewString() var testNetworkId = uuid.NewString() var testNicId = uuid.NewString() @@ -35,8 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - networkIdFlag: testNetworkId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + networkIdFlag: testNetworkId, } for _, mod := range mods { mod(flagValues) @@ -49,8 +55,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - NetworkId: utils.Ptr(testNetworkId), + NetworkId: testNetworkId, NicId: testNicId, } for _, mod := range mods { @@ -60,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteNicRequest)) iaas.ApiDeleteNicRequest { - request := testClient.DeleteNic(testCtx, testProjectId, testNetworkId, testNicId) + request := testClient.DeleteNic(testCtx, testProjectId, testRegion, testNetworkId, testNicId) for _, mod := range mods { mod(&request) } @@ -122,7 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/network-interface/describe/describe.go b/internal/cmd/network-interface/describe/describe.go index 89b04b9b3..49e93d690 100644 --- a/internal/cmd/network-interface/describe/describe.go +++ b/internal/cmd/network-interface/describe/describe.go @@ -2,12 +2,14 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -28,11 +29,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - NetworkId *string + NetworkId string NicId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", nicIdArg), Short: "Describes a network interface", @@ -54,13 +55,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -72,7 +73,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe network interface: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -95,24 +96,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, - NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag), + NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag), NicId: nicId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetNicRequest { - req := apiClient.GetNic(ctx, model.ProjectId, *model.NetworkId, model.NicId) + req := apiClient.GetNic(ctx, model.ProjectId, model.Region, model.NetworkId, model.NicId) return req } @@ -120,24 +113,7 @@ func outputResult(p *print.Printer, outputFormat string, nic *iaas.NIC) error { if nic == nil { return fmt.Errorf("nic is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(nic, "", " ") - if err != nil { - return fmt.Errorf("marshal network interface: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(nic, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network interface: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, nic, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(nic.Id)) table.AddSeparator() @@ -188,5 +164,5 @@ func outputResult(p *print.Printer, outputFormat string, nic *iaas.NIC) error { return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/network-interface/describe/describe_test.go b/internal/cmd/network-interface/describe/describe_test.go index 967057b02..6403f68bf 100644 --- a/internal/cmd/network-interface/describe/describe_test.go +++ b/internal/cmd/network-interface/describe/describe_test.go @@ -4,13 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + testRegion = "eu01" ) type testCtxKey struct{} @@ -18,7 +24,6 @@ type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &iaas.APIClient{} -var projectIdFlag = globalflags.ProjectIdFlag var testProjectId = uuid.NewString() var testNetworkId = uuid.NewString() var testNicId = uuid.NewString() @@ -35,8 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - networkIdFlag: testNetworkId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + networkIdFlag: testNetworkId, } for _, mod := range mods { mod(flagValues) @@ -49,8 +55,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - NetworkId: utils.Ptr(testNetworkId), + NetworkId: testNetworkId, NicId: testNicId, } for _, mod := range mods { @@ -60,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetNicRequest)) iaas.ApiGetNicRequest { - request := testClient.GetNic(testCtx, testProjectId, testNetworkId, testNicId) + request := testClient.GetNic(testCtx, testProjectId, testRegion, testNetworkId, testNicId) for _, mod := range mods { mod(&request) } @@ -122,7 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -225,7 +232,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.nic); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network-interface/list/list.go b/internal/cmd/network-interface/list/list.go index 3164f956b..33941d1de 100644 --- a/internal/cmd/network-interface/list/list.go +++ b/internal/cmd/network-interface/list/list.go @@ -1,12 +1,17 @@ package list import ( + "cmp" "context" - "encoding/json" "fmt" + "slices" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +22,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -33,13 +37,18 @@ type inputModel struct { NetworkId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all network interfaces of a network", Long: "Lists all network interfaces of a network.", Args: args.NoArgs, Example: examples.Build( + // Note: this subcommand uses two different API enpoints, which makes the implementation somewhat messy + examples.NewExample( + `Lists all network interfaces`, + `$ stackit network-interface list`, + ), examples.NewExample( `Lists all network interfaces with network ID "xxx"`, `$ stackit network-interface list --network-id xxx`, @@ -57,45 +66,65 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit network-interface list --network-id xxx --limit 10`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - // Call API - req := buildRequest(ctx, model, apiClient) + if model.NetworkId == nil { + // Call API to get all NICs in the Project + req := buildProjectRequest(ctx, model, apiClient) + + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list network interfaces: %w", err) + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + // Truncate output + items := utils.GetSliceFromPointer(resp.Items) + if model.Limit != nil && len(items) > int(*model.Limit) { + items = items[:*model.Limit] + } + + return outputProjectResult(params.Printer, model.OutputFormat, items, projectLabel) + } + + // Call API to get NICs for one Network + req := buildNetworkRequest(ctx, model, apiClient) + resp, err := req.Execute() if err != nil { return fmt.Errorf("list network interfaces: %w", err) } - if resp.Items == nil || len(*resp.Items) == 0 { - networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, *model.NetworkId) - if err != nil { - p.Debug(print.ErrorLevel, "get network name: %v", err) - networkLabel = *model.NetworkId - } else if networkLabel == "" { - networkLabel = *model.NetworkId - } - p.Info("No network interfaces found for network %q\n", networkLabel) - return nil + networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, model.Region, *model.NetworkId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get network name: %v", err) + networkLabel = *model.NetworkId + } else if networkLabel == "" { + networkLabel = *model.NetworkId } // Truncate output - items := *resp.Items + items := utils.GetSliceFromPointer(resp.Items) if model.Limit != nil && len(items) > int(*model.Limit) { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputNetworkResult(params.Printer, model.OutputFormat, items, networkLabel) }, } configureFlags(cmd) @@ -106,12 +135,9 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID") cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") cmd.Flags().String(labelSelectorFlag, "", "Filter by label") - - err := flags.MarkFlagsRequired(cmd, networkIdFlag) - cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -132,20 +158,21 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + p.DebugInputModel(model) + return &model, nil +} + +func buildProjectRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListProjectNICsRequest { + req := apiClient.ListProjectNICs(ctx, model.ProjectId, model.Region) + if model.LabelSelector != nil { + req = req.LabelSelector(*model.LabelSelector) } - return &model, nil + return req } -func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListNicsRequest { - req := apiClient.ListNics(ctx, model.ProjectId, *model.NetworkId) +func buildNetworkRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListNicsRequest { + req := apiClient.ListNics(ctx, model.ProjectId, model.Region, *model.NetworkId) if model.LabelSelector != nil { req = req.LabelSelector(*model.LabelSelector) } @@ -153,25 +180,46 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli return req } -func outputResult(p *print.Printer, outputFormat string, nics []iaas.NIC) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(nics, "", " ") - if err != nil { - return fmt.Errorf("marshal nics: %w", err) +func outputProjectResult(p *print.Printer, outputFormat string, nics []iaas.NIC, projectLabel string) error { + return p.OutputResult(outputFormat, nics, func() error { + if len(nics) == 0 { + p.Outputf("No network interfaces found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(nics, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal nics: %w", err) + slices.SortFunc(nics, func(a, b iaas.NIC) int { + return cmp.Compare(utils.PtrValue(a.NetworkId), utils.PtrValue(b.NetworkId)) + }) + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "NETWORK ID", "NIC SECURITY", "DEVICE ID", "IPv4 ADDRESS", "STATUS", "TYPE") + + for _, nic := range nics { + table.AddRow( + utils.PtrString(nic.Id), + utils.PtrString(nic.Name), + utils.PtrString(nic.NetworkId), + utils.PtrString(nic.NicSecurity), + utils.PtrString(nic.Device), + utils.PtrString(nic.Ipv4), + utils.PtrString(nic.Status), + utils.PtrString(nic.Type), + ) + table.AddSeparator() } - p.Outputln(string(details)) + p.Outputln(table.Render()) return nil - default: + }) +} + +func outputNetworkResult(p *print.Printer, outputFormat string, nics []iaas.NIC, networkLabel string) error { + return p.OutputResult(outputFormat, nics, func() error { + if len(nics) == 0 { + p.Outputf("No network interfaces found for network %q\n", networkLabel) + return nil + } + table := tables.NewTable() table.SetHeader("ID", "NAME", "NIC SECURITY", "DEVICE ID", "IPv4 ADDRESS", "STATUS", "TYPE") @@ -190,5 +238,5 @@ func outputResult(p *print.Printer, outputFormat string, nics []iaas.NIC) error p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/network-interface/list/list_test.go b/internal/cmd/network-interface/list/list_test.go index 97610156a..6c8d5b01e 100644 --- a/internal/cmd/network-interface/list/list_test.go +++ b/internal/cmd/network-interface/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -26,7 +31,9 @@ var testLabelSelector = "label" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + networkIdFlag: testNetworkId, limitFlag: "10", labelSelectorFlag: testLabelSelector, @@ -42,6 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), LabelSelector: utils.Ptr(testLabelSelector), @@ -53,8 +61,17 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func fixtureRequest(mods ...func(request *iaas.ApiListNicsRequest)) iaas.ApiListNicsRequest { - request := testClient.ListNics(testCtx, testProjectId, testNetworkId) +func fixtureProjectRequest(mods ...func(request *iaas.ApiListProjectNICsRequest)) iaas.ApiListProjectNICsRequest { + request := testClient.ListProjectNICs(testCtx, testProjectId, testRegion) + request = request.LabelSelector(testLabelSelector) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixtureNetworkRequest(mods ...func(request *iaas.ApiListNicsRequest)) iaas.ApiListNicsRequest { + request := testClient.ListNics(testCtx, testProjectId, testRegion, testNetworkId) request = request.LabelSelector(testLabelSelector) for _, mod := range mods { mod(&request) @@ -65,6 +82,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListNicsRequest)) iaas.ApiList func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -88,21 +106,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -134,43 +152,32 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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) - } - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } +func TestBuildProjectRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListProjectNICsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureProjectRequest(), + }, + } - model, err := parseInput(p, cmd) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error parsing input: %v", err) - } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildProjectRequest(testCtx, tt.model, testClient) - if !tt.isValid { - t.Fatalf("did not fail on invalid input") - } - diff := cmp.Diff(model, tt.expectedModel) + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) if diff != "" { t.Fatalf("Data does not match: %s", diff) } @@ -178,7 +185,7 @@ func TestParseInput(t *testing.T) { } } -func TestBuildRequest(t *testing.T) { +func TestBuildNetworkRequest(t *testing.T) { tests := []struct { description string model *inputModel @@ -187,13 +194,13 @@ func TestBuildRequest(t *testing.T) { { description: "base", model: fixtureInputModel(), - expectedRequest: fixtureRequest(), + expectedRequest: fixtureNetworkRequest(), }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request := buildRequest(testCtx, tt.model, testClient) + request := buildNetworkRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), @@ -206,7 +213,7 @@ func TestBuildRequest(t *testing.T) { } } -func TestOutputResult(t *testing.T) { +func TestOutputProjectResult(t *testing.T) { type args struct { outputFormat string nics []iaas.NIC @@ -217,16 +224,87 @@ func TestOutputResult(t *testing.T) { wantErr bool }{ { - name: "empty", - args: args{}, + name: "nil as NIC-slice", + args: args{ + outputFormat: print.PrettyOutputFormat, + }, + wantErr: false, + }, + { + name: "empty NIC-slice", + args: args{ + outputFormat: print.PrettyOutputFormat, + nics: []iaas.NIC{}, + }, + wantErr: false, + }, + { + name: "empty NIC in NIC-slice", + args: args{ + outputFormat: print.PrettyOutputFormat, + nics: []iaas.NIC{{}}, + }, + wantErr: false, + }, + { + name: "two empty NICs in NIC-slice to verify sorting by network id does not break on nil pointers", + args: args{ + outputFormat: print.PrettyOutputFormat, + nics: []iaas.NIC{{}, {}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputProjectResult(p, tt.args.outputFormat, tt.args.nics, ""); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestOutputNetworkResult(t *testing.T) { + type args struct { + outputFormat string + nics []iaas.NIC + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "nil as NIC-slice", + args: args{ + outputFormat: print.PrettyOutputFormat, + }, + wantErr: false, + }, + { + name: "empty NIC-slice", + args: args{ + outputFormat: print.PrettyOutputFormat, + nics: []iaas.NIC{}, + }, + wantErr: false, + }, + { + name: "empty NIC in NIC-slice", + args: args{ + outputFormat: print.PrettyOutputFormat, + nics: []iaas.NIC{{}}, + }, wantErr: false, }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.nics); (err != nil) != tt.wantErr { + if err := outputNetworkResult(p, tt.args.outputFormat, tt.args.nics, ""); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/network-interface/network-interface.go b/internal/cmd/network-interface/network-interface.go index cada28596..3b9c2fb48 100644 --- a/internal/cmd/network-interface/network-interface.go +++ b/internal/cmd/network-interface/network-interface.go @@ -2,17 +2,18 @@ package networkinterface import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/network-interface/create" "github.com/stackitcloud/stackit-cli/internal/cmd/network-interface/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/network-interface/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/network-interface/list" "github.com/stackitcloud/stackit-cli/internal/cmd/network-interface/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "network-interface", Short: "Provides functionality for network interfaces", @@ -20,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/network-interface/update/update.go b/internal/cmd/network-interface/update/update.go index 777f2f4b4..e1b9b6e1e 100644 --- a/internal/cmd/network-interface/update/update.go +++ b/internal/cmd/network-interface/update/update.go @@ -2,12 +2,14 @@ package update import ( "context" - "encoding/json" "fmt" "regexp" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -39,7 +40,7 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel NicId string - NetworkId *string + NetworkId string AllowedAddresses *[]iaas.AllowedAddressesInner Labels *map[string]string Name *string // <= 63 characters + regex ^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$ @@ -47,7 +48,7 @@ type inputModel struct { SecurityGroups *[]string // = 36 characters + regex ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", nicIdArg), Short: "Updates a network interface", @@ -69,23 +70,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update the network interface %q?", model.NicId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update the network interface %q?", model.NicId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -95,7 +94,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update network interface: %w", err) } - return outputResult(p, model.OutputFormat, model.ProjectId, resp) + return outputResult(params.Printer, model.OutputFormat, model.ProjectId, resp) }, } configureFlags(cmd) @@ -172,7 +171,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, NicId: nicId, - NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag), + NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), Name: name, NicSecurity: flags.FlagToBoolPointer(p, cmd, nicSecurityFlag), @@ -183,34 +182,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model.AllowedAddresses = utils.Ptr(allowedAddressesInner) } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateNicRequest { - req := apiClient.UpdateNic(ctx, model.ProjectId, *model.NetworkId, model.NicId) - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - convertedMap := make(map[string]interface{}, len(*model.Labels)) - for k, v := range *model.Labels { - convertedMap[k] = v - } - labelsMap = &convertedMap - } + req := apiClient.UpdateNic(ctx, model.ProjectId, model.Region, model.NetworkId, model.NicId) payload := iaas.UpdateNicPayload{ AllowedAddresses: model.AllowedAddresses, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), Name: model.Name, NicSecurity: model.NicSecurity, SecurityGroups: model.SecurityGroups, @@ -222,25 +203,8 @@ func outputResult(p *print.Printer, outputFormat, projectId string, nic *iaas.NI if nic == nil { return fmt.Errorf("nic is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(nic, "", " ") - if err != nil { - return fmt.Errorf("marshal network interface: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(nic, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network interface: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, nic, func() error { p.Outputf("Updated network interface for project %q.\n", projectId) return nil - } + }) } diff --git a/internal/cmd/network-interface/update/update_test.go b/internal/cmd/network-interface/update/update_test.go index 98987b829..7eba7d62d 100644 --- a/internal/cmd/network-interface/update/update_test.go +++ b/internal/cmd/network-interface/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -37,7 +41,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + networkIdFlag: testNetworkId, allowedAddressesFlag: "1.1.1.1,8.8.8.8,9.9.9.9", labelFlag: "key=value", @@ -52,7 +58,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st } func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { - var allowedAddresses []iaas.AllowedAddressesInner = []iaas.AllowedAddressesInner{ + var allowedAddresses = []iaas.AllowedAddressesInner{ iaas.StringAsAllowedAddressesInner(utils.Ptr("1.1.1.1")), iaas.StringAsAllowedAddressesInner(utils.Ptr("8.8.8.8")), iaas.StringAsAllowedAddressesInner(utils.Ptr("9.9.9.9")), @@ -61,8 +67,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - NetworkId: utils.Ptr(testNetworkId), + NetworkId: testNetworkId, AllowedAddresses: utils.Ptr(allowedAddresses), Labels: utils.Ptr(map[string]string{ "key": "value", @@ -79,7 +86,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUpdateNicRequest)) iaas.ApiUpdateNicRequest { - request := testClient.UpdateNic(testCtx, testProjectId, testNetworkId, testNicId) + request := testClient.UpdateNic(testCtx, testProjectId, testRegion, testNetworkId, testNicId) request = request.UpdateNicPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -88,7 +95,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiUpdateNicRequest)) iaas.ApiUpd } func fixturePayload(mods ...func(payload *iaas.UpdateNicPayload)) iaas.UpdateNicPayload { - var allowedAddresses []iaas.AllowedAddressesInner = []iaas.AllowedAddressesInner{ + var allowedAddresses = []iaas.AllowedAddressesInner{ iaas.StringAsAllowedAddressesInner(utils.Ptr("1.1.1.1")), iaas.StringAsAllowedAddressesInner(utils.Ptr("8.8.8.8")), iaas.StringAsAllowedAddressesInner(utils.Ptr("9.9.9.9")), @@ -213,7 +220,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -317,7 +324,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.projectId, tt.args.nic); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network/create/create.go b/internal/cmd/network/create/create.go index b89f54f49..46e9e0e57 100644 --- a/internal/cmd/network/create/create.go +++ b/internal/cmd/network/create/create.go @@ -2,10 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,8 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -35,6 +36,7 @@ const ( nonRoutedFlag = "non-routed" noIpv4GatewayFlag = "no-ipv4-gateway" noIpv6GatewayFlag = "no-ipv6-gateway" + routingTableIdFlag = "routing-table-id" labelFlag = "labels" ) @@ -52,10 +54,11 @@ type inputModel struct { NonRouted bool NoIPv4Gateway bool NoIPv6Gateway bool + RoutingTableID *string Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a network", @@ -80,40 +83,42 @@ func NewCmd(p *print.Printer) *cobra.Command { ), examples.NewExample( `Create an IPv4 network with name "network-1" with DNS name servers, a prefix and a gateway`, - `$ stackit network create --name network-1 --ipv4-dns-name-servers "1.1.1.1,8.8.8.8,9.9.9.9" --ipv4-prefix "10.1.2.0/24" --ipv4-gateway "10.1.2.3"`, + `$ stackit network create --name network-1 --non-routed --ipv4-dns-name-servers "1.1.1.1,8.8.8.8,9.9.9.9" --ipv4-prefix "10.1.2.0/24" --ipv4-gateway "10.1.2.3"`, ), examples.NewExample( `Create an IPv6 network with name "network-1" with DNS name servers, a prefix and a gateway`, `$ stackit network create --name network-1 --ipv6-dns-name-servers "2001:4860:4860::8888,2001:4860:4860::8844" --ipv6-prefix "2001:4860:4860::8888" --ipv6-gateway "2001:4860:4860::8888"`, ), + examples.NewExample( + `Create a network with name "network-1" and attach routing-table "xxx"`, + `$ stackit network create --name network-1 --routing-table-id xxx`, + ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a network for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a network for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -122,20 +127,23 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("create network : %w", err) } - networkId := *resp.NetworkId + + if resp == nil || resp.Id == nil { + return fmt.Errorf("create network : empty response") + } + networkId := *resp.Id // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating network") - _, err = wait.CreateNetworkWaitHandler(ctx, apiClient, model.ProjectId, networkId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating network", func() error { + _, err = wait.CreateNetworkWaitHandler(ctx, apiClient, model.ProjectId, model.Region, networkId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for network creation: %w", err) } - s.Stop() } - - return outputResult(p, model.OutputFormat, model.Async, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) }, } configureFlags(cmd) @@ -155,13 +163,25 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(nonRoutedFlag, false, "If set to true, the network is not routed and therefore not accessible from other networks") cmd.Flags().Bool(noIpv4GatewayFlag, false, "If set to true, the network doesn't have an IPv4 gateway") cmd.Flags().Bool(noIpv6GatewayFlag, false, "If set to true, the network doesn't have an IPv6 gateway") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "The ID of the routing-table for the network") cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network. E.g. '--labels key1=value1,key2=value2,...'") + // IPv4 checks + cmd.MarkFlagsMutuallyExclusive(ipv4PrefixFlag, ipv4PrefixLengthFlag) + cmd.MarkFlagsMutuallyExclusive(ipv4GatewayFlag, ipv4PrefixLengthFlag) + cmd.MarkFlagsMutuallyExclusive(ipv4GatewayFlag, noIpv4GatewayFlag) + cmd.MarkFlagsMutuallyExclusive(noIpv4GatewayFlag, ipv4PrefixLengthFlag) + + // IPv6 checks + cmd.MarkFlagsMutuallyExclusive(ipv6PrefixFlag, ipv6PrefixLengthFlag) + cmd.MarkFlagsMutuallyExclusive(ipv6GatewayFlag, ipv6PrefixLengthFlag) + cmd.MarkFlagsMutuallyExclusive(ipv6GatewayFlag, noIpv6GatewayFlag) + err := flags.MarkFlagsRequired(cmd, nameFlag) cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -174,6 +194,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { IPv4PrefixLength: flags.FlagToInt64Pointer(p, cmd, ipv4PrefixLengthFlag), IPv4Prefix: flags.FlagToStringPointer(p, cmd, ipv4PrefixFlag), IPv4Gateway: flags.FlagToStringPointer(p, cmd, ipv4GatewayFlag), + IPv6DnsNameServers: flags.FlagToStringSlicePointer(p, cmd, ipv6DnsNameServersFlag), IPv6PrefixLength: flags.FlagToInt64Pointer(p, cmd, ipv6PrefixLengthFlag), IPv6Prefix: flags.FlagToStringPointer(p, cmd, ipv6PrefixFlag), @@ -181,75 +202,106 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { NonRouted: flags.FlagToBoolValue(p, cmd, nonRoutedFlag), NoIPv4Gateway: flags.FlagToBoolValue(p, cmd, noIpv4GatewayFlag), NoIPv6Gateway: flags.FlagToBoolValue(p, cmd, noIpv6GatewayFlag), + RoutingTableID: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + // IPv4 nameserver can not be set alone. IPv4 Prefix || IPv4 Prefix length must be set as well + isIPv4NameserverSet := model.IPv4DnsNameServers != nil && len(*model.IPv4DnsNameServers) > 0 + isIPv4PrefixOrPrefixLengthSet := model.IPv4Prefix != nil || model.IPv4PrefixLength != nil + if isIPv4NameserverSet && !isIPv4PrefixOrPrefixLengthSet { + return nil, &cliErr.OneOfFlagsIsMissing{ + MissingFlags: []string{ipv4PrefixLengthFlag, ipv4PrefixFlag}, + SetFlag: ipv4DnsNameServersFlag, + } + } + isIPv4GatewaySet := model.IPv4Gateway != nil + isIPv4PrefixSet := model.IPv4Prefix != nil + if isIPv4GatewaySet && !isIPv4PrefixSet { + return nil, &cliErr.DependingFlagIsMissing{ + MissingFlag: ipv4PrefixFlag, + SetFlag: ipv4GatewayFlag, + } + } + + // IPv6 nameserver can not be set alone. IPv6 Prefix || IPv6 Prefix length must be set as well + isIPv6NameserverSet := model.IPv6DnsNameServers != nil && len(*model.IPv6DnsNameServers) > 0 + isIPv6PrefixOrPrefixLengthSet := model.IPv6Prefix != nil || model.IPv6PrefixLength != nil + if isIPv6NameserverSet && !isIPv6PrefixOrPrefixLengthSet { + return nil, &cliErr.OneOfFlagsIsMissing{ + MissingFlags: []string{ipv6PrefixLengthFlag, ipv6PrefixFlag}, + SetFlag: ipv6DnsNameServersFlag, + } + } + isIPv6GatewaySet := model.IPv6Gateway != nil + isIPv6PrefixSet := model.IPv6Prefix != nil + if isIPv6GatewaySet && !isIPv6PrefixSet { + return nil, &cliErr.DependingFlagIsMissing{ + MissingFlag: ipv6PrefixFlag, + SetFlag: ipv6GatewayFlag, } } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateNetworkRequest { - req := apiClient.CreateNetwork(ctx, model.ProjectId) - addressFamily := &iaas.CreateNetworkAddressFamily{} - - if model.IPv6DnsNameServers != nil || model.IPv6PrefixLength != nil || model.IPv6Prefix != nil || model.NoIPv6Gateway || model.IPv6Gateway != nil { - addressFamily.Ipv6 = &iaas.CreateNetworkIPv6Body{ - Nameservers: model.IPv6DnsNameServers, - PrefixLength: model.IPv6PrefixLength, - Prefix: model.IPv6Prefix, + req := apiClient.CreateNetwork(ctx, model.ProjectId, model.Region) + var ipv4Network *iaas.CreateNetworkIPv4 + var ipv6Network *iaas.CreateNetworkIPv6 + + if model.IPv6Prefix != nil { + ipv6Network = &iaas.CreateNetworkIPv6{ + CreateNetworkIPv6WithPrefix: &iaas.CreateNetworkIPv6WithPrefix{ + Prefix: model.IPv6Prefix, + Nameservers: model.IPv6DnsNameServers, + }, } if model.NoIPv6Gateway { - addressFamily.Ipv6.Gateway = iaas.NewNullableString(nil) + ipv6Network.CreateNetworkIPv6WithPrefix.Gateway = iaas.NewNullableString(nil) } else if model.IPv6Gateway != nil { - addressFamily.Ipv6.Gateway = iaas.NewNullableString(model.IPv6Gateway) + ipv6Network.CreateNetworkIPv6WithPrefix.Gateway = iaas.NewNullableString(model.IPv6Gateway) + } + } else if model.IPv6PrefixLength != nil { + ipv6Network = &iaas.CreateNetworkIPv6{ + CreateNetworkIPv6WithPrefixLength: &iaas.CreateNetworkIPv6WithPrefixLength{ + PrefixLength: model.IPv6PrefixLength, + Nameservers: model.IPv6DnsNameServers, + }, } } - if model.IPv4DnsNameServers != nil || model.IPv4PrefixLength != nil || model.IPv4Prefix != nil || model.NoIPv4Gateway || model.IPv4Gateway != nil { - addressFamily.Ipv4 = &iaas.CreateNetworkIPv4Body{ - Nameservers: model.IPv4DnsNameServers, - PrefixLength: model.IPv4PrefixLength, - Prefix: model.IPv4Prefix, + if model.IPv4Prefix != nil { + ipv4Network = &iaas.CreateNetworkIPv4{ + CreateNetworkIPv4WithPrefix: &iaas.CreateNetworkIPv4WithPrefix{ + Prefix: model.IPv4Prefix, + Nameservers: model.IPv4DnsNameServers, + }, } if model.NoIPv4Gateway { - addressFamily.Ipv4.Gateway = iaas.NewNullableString(nil) + ipv4Network.CreateNetworkIPv4WithPrefix.Gateway = iaas.NewNullableString(nil) } else if model.IPv4Gateway != nil { - addressFamily.Ipv4.Gateway = iaas.NewNullableString(model.IPv4Gateway) + ipv4Network.CreateNetworkIPv4WithPrefix.Gateway = iaas.NewNullableString(model.IPv4Gateway) } - } - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v + } else if model.IPv4PrefixLength != nil { + ipv4Network = &iaas.CreateNetworkIPv4{ + CreateNetworkIPv4WithPrefixLength: &iaas.CreateNetworkIPv4WithPrefixLength{ + PrefixLength: model.IPv4PrefixLength, + Nameservers: model.IPv4DnsNameServers, + }, } } - routed := true - if model.NonRouted { - routed = false - } - payload := iaas.CreateNetworkPayload{ - Name: model.Name, - Labels: labelsMap, - Routed: &routed, - } - - if addressFamily.Ipv4 != nil || addressFamily.Ipv6 != nil { - payload.AddressFamily = addressFamily + Name: model.Name, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + Routed: utils.Ptr(!model.NonRouted), + Ipv4: ipv4Network, + Ipv6: ipv6Network, + RoutingTableId: model.RoutingTableID, } return req.CreateNetworkPayload(payload) @@ -259,29 +311,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, projectLabe if network == nil { return fmt.Errorf("network cannot be nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(network, "", " ") - if err != nil { - return fmt.Errorf("marshal network: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(network, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, network, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } - p.Outputf("%s network for project %q.\nNetwork ID: %s\n", operationState, projectLabel, utils.PtrString(network.NetworkId)) + p.Outputf("%s network for project %q.\nNetwork ID: %s\n", operationState, projectLabel, utils.PtrString(network.Id)) return nil - } + }) } diff --git a/internal/cmd/network/create/create_test.go b/internal/cmd/network/create/create_test.go index 19edc1d40..c933f5a7b 100644 --- a/internal/cmd/network/create/create_test.go +++ b/internal/cmd/network/create/create_test.go @@ -2,10 +2,15 @@ package create import ( "context" + "strconv" + "strings" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,30 +19,73 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" -type testCtxKey struct{} + testNetworkName = "example-network-name" + testIPv4PrefixLength int64 = 24 + testIPv4Prefix = "10.1.2.0/24" + testIPv4Gateway = "10.1.2.3" + testIPv6PrefixLength int64 = 24 + testIPv6Prefix = "2001:4860:4860::/64" + testIPv6Gateway = "2001:db8:0:8d3:0:8a2e:70:1" + testNonRouted = false +) -var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &iaas.APIClient{} +var ( + testIPv4NameServers = []string{"1.1.1.0", "1.1.2.0"} + testIPv6NameServers = []string{"2001:4860:4860::8888", "2001:4860:4860::8844"} +) -var testProjectId = uuid.NewString() +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testRoutingTableId = uuid.NewString() +) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - nameFlag: "example-network-name", - ipv4DnsNameServersFlag: "1.1.1.0,1.1.2.0", - ipv4PrefixLengthFlag: "24", - ipv4PrefixFlag: "10.1.2.0/24", - ipv4GatewayFlag: "10.1.2.3", - ipv6DnsNameServersFlag: "2001:4860:4860::8888,2001:4860:4860::8844", - ipv6PrefixLengthFlag: "24", - ipv6PrefixFlag: "2001:4860:4860::8888", - ipv6GatewayFlag: "2001:4860:4860::8888", - nonRoutedFlag: "false", - labelFlag: "key=value", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + nameFlag: testNetworkName, + nonRoutedFlag: strconv.FormatBool(testNonRouted), + labelFlag: "key=value", + routingTableIdFlag: testRoutingTableId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureFlagValuesWithPrefix(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4DnsNameServersFlag] = strings.Join(testIPv4NameServers, ",") + flagValues[ipv4PrefixFlag] = testIPv4Prefix + flagValues[ipv4GatewayFlag] = testIPv4Gateway + + flagValues[ipv6DnsNameServersFlag] = strings.Join(testIPv6NameServers, ",") + flagValues[ipv6PrefixFlag] = testIPv6Prefix + flagValues[ipv6GatewayFlag] = testIPv6Gateway + }) + for _, mod := range mods { + mod(flagValues) } + return flagValues +} + +func fixtureFlagValuesWithPrefixLength(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4PrefixLengthFlag] = strconv.FormatInt(testIPv4PrefixLength, 10) + flagValues[ipv4DnsNameServersFlag] = strings.Join(testIPv4NameServers, ",") + + flagValues[ipv6PrefixLengthFlag] = strconv.FormatInt(testIPv6PrefixLength, 10) + flagValues[ipv6DnsNameServersFlag] = strings.Join(testIPv6NameServers, ",") + }) for _, mod := range mods { mod(flagValues) } @@ -49,20 +97,14 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - Name: utils.Ptr("example-network-name"), - IPv4DnsNameServers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}), - IPv4PrefixLength: utils.Ptr(int64(24)), - IPv4Prefix: utils.Ptr("10.1.2.0/24"), - IPv4Gateway: utils.Ptr("10.1.2.3"), - IPv6DnsNameServers: utils.Ptr([]string{"2001:4860:4860::8888", "2001:4860:4860::8844"}), - IPv6PrefixLength: utils.Ptr(int64(24)), - IPv6Prefix: utils.Ptr("2001:4860:4860::8888"), - IPv6Gateway: utils.Ptr("2001:4860:4860::8888"), - NonRouted: false, + Name: utils.Ptr(testNetworkName), + NonRouted: testNonRouted, Labels: utils.Ptr(map[string]string{ "key": "value", }), + RoutingTableID: utils.Ptr(testRoutingTableId), } for _, mod := range mods { mod(model) @@ -70,8 +112,40 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } +func fixtureInputModelWithPrefix(mods ...func(model *inputModel)) *inputModel { + model := fixtureInputModel() + + model.IPv4DnsNameServers = utils.Ptr(testIPv4NameServers) + model.IPv4Prefix = utils.Ptr(testIPv4Prefix) + model.IPv4Gateway = utils.Ptr(testIPv4Gateway) + + model.IPv6DnsNameServers = utils.Ptr(testIPv6NameServers) + model.IPv6Prefix = utils.Ptr(testIPv6Prefix) + model.IPv6Gateway = utils.Ptr(testIPv6Gateway) + + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureInputModelWithPrefixLength(mods ...func(model *inputModel)) *inputModel { + model := fixtureInputModel() + + model.IPv4DnsNameServers = utils.Ptr(testIPv4NameServers) + model.IPv4PrefixLength = utils.Ptr(testIPv4PrefixLength) + + model.IPv6DnsNameServers = utils.Ptr(testIPv6NameServers) + model.IPv6PrefixLength = utils.Ptr(testIPv6PrefixLength) + + for _, mod := range mods { + mod(model) + } + return model +} + func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkRequest)) iaas.ApiCreateNetworkRequest { - request := testClient.CreateNetwork(testCtx, testProjectId) + request := testClient.CreateNetwork(testCtx, testProjectId, testRegion) request = request.CreateNetworkPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -80,9 +154,9 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkRequest)) iaas.Ap } func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateNetworkRequest)) iaas.ApiCreateNetworkRequest { - request := testClient.CreateNetwork(testCtx, testProjectId) + request := testClient.CreateNetwork(testCtx, testProjectId, testRegion) request = request.CreateNetworkPayload(iaas.CreateNetworkPayload{ - Name: utils.Ptr("example-network-name"), + Name: utils.Ptr(testNetworkName), Routed: utils.Ptr(true), }) for _, mod := range mods { @@ -98,19 +172,48 @@ func fixturePayload(mods ...func(payload *iaas.CreateNetworkPayload)) iaas.Creat Labels: utils.Ptr(map[string]interface{}{ "key": "value", }), - AddressFamily: &iaas.CreateNetworkAddressFamily{ - Ipv4: &iaas.CreateNetworkIPv4Body{ - Nameservers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}), - PrefixLength: utils.Ptr(int64(24)), - Prefix: utils.Ptr("10.1.2.0/24"), - Gateway: iaas.NewNullableString(utils.Ptr("10.1.2.3")), - }, - Ipv6: &iaas.CreateNetworkIPv6Body{ - Nameservers: utils.Ptr([]string{"2001:4860:4860::8888", "2001:4860:4860::8844"}), - PrefixLength: utils.Ptr(int64(24)), - Prefix: utils.Ptr("2001:4860:4860::8888"), - Gateway: iaas.NewNullableString(utils.Ptr("2001:4860:4860::8888")), - }, + RoutingTableId: utils.Ptr(testRoutingTableId), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixturePayloadWithPrefix(mods ...func(payload *iaas.CreateNetworkPayload)) iaas.CreateNetworkPayload { + payload := fixturePayload() + payload.Ipv4 = &iaas.CreateNetworkIPv4{ + CreateNetworkIPv4WithPrefix: &iaas.CreateNetworkIPv4WithPrefix{ + Gateway: iaas.NewNullableString(utils.Ptr(testIPv4Gateway)), + Nameservers: utils.Ptr(testIPv4NameServers), + Prefix: utils.Ptr(testIPv4Prefix), + }, + } + payload.Ipv6 = &iaas.CreateNetworkIPv6{ + CreateNetworkIPv6WithPrefix: &iaas.CreateNetworkIPv6WithPrefix{ + Nameservers: utils.Ptr(testIPv6NameServers), + Prefix: utils.Ptr(testIPv6Prefix), + Gateway: iaas.NewNullableString(utils.Ptr(testIPv6Gateway)), + }, + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixturePayloadWithPrefixLength(mods ...func(payload *iaas.CreateNetworkPayload)) iaas.CreateNetworkPayload { + payload := fixturePayload() + payload.Ipv4 = &iaas.CreateNetworkIPv4{ + CreateNetworkIPv4WithPrefixLength: &iaas.CreateNetworkIPv4WithPrefixLength{ + PrefixLength: utils.Ptr(testIPv4PrefixLength), + Nameservers: utils.Ptr(testIPv4NameServers), + }, + } + payload.Ipv6 = &iaas.CreateNetworkIPv6{ + CreateNetworkIPv6WithPrefixLength: &iaas.CreateNetworkIPv6WithPrefixLength{ + PrefixLength: utils.Ptr(testIPv6PrefixLength), + Nameservers: utils.Ptr(testIPv6NameServers), }, } for _, mod := range mods { @@ -122,6 +225,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateNetworkPayload)) iaas.Creat func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -134,15 +238,21 @@ func TestParseInput(t *testing.T) { }, { description: "required only", - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, ipv4DnsNameServersFlag) - delete(flagValues, ipv4PrefixLengthFlag) - }), + flagValues: map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + nameFlag: testNetworkName, + }, isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.IPv4DnsNameServers = nil - model.IPv4PrefixLength = nil - }), + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + Name: utils.Ptr(testNetworkName), + }, }, { description: "name missing", @@ -159,66 +269,110 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, { - description: "use dns servers, prefix, gateway and prefix length", - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[ipv4DnsNameServersFlag] = "1.1.1.1" - flagValues[ipv4PrefixLengthFlag] = "25" - flagValues[ipv4PrefixFlag] = "10.1.2.0/24" - flagValues[ipv4GatewayFlag] = "10.1.2.3" + description: "use with prefix", + flagValues: fixtureFlagValuesWithPrefix(), + isValid: true, + expectedModel: fixtureInputModelWithPrefix(), + }, + { + description: "use with prefix only ipv4", + flagValues: fixtureFlagValuesWithPrefix(func(flagValues map[string]string) { + delete(flagValues, ipv6GatewayFlag) + delete(flagValues, ipv6PrefixFlag) + delete(flagValues, ipv6PrefixLengthFlag) + delete(flagValues, ipv6DnsNameServersFlag) }), isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.IPv4DnsNameServers = utils.Ptr([]string{"1.1.1.1"}) - model.IPv4PrefixLength = utils.Ptr(int64(25)) - model.IPv4Prefix = utils.Ptr("10.1.2.0/24") - model.IPv4Gateway = utils.Ptr("10.1.2.3") + expectedModel: fixtureInputModelWithPrefix(func(model *inputModel) { + model.IPv6PrefixLength = nil + model.IPv6Prefix = nil + model.IPv6DnsNameServers = nil + model.IPv6Gateway = nil }), }, { - description: "use ipv4 gateway nil", - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[noIpv4GatewayFlag] = "true" + description: "use with prefix only ipv6", + flagValues: fixtureFlagValuesWithPrefix(func(flagValues map[string]string) { delete(flagValues, ipv4GatewayFlag) + delete(flagValues, ipv4PrefixFlag) + delete(flagValues, ipv4PrefixLengthFlag) + delete(flagValues, ipv4DnsNameServersFlag) }), isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.NoIPv4Gateway = true + expectedModel: fixtureInputModelWithPrefix(func(model *inputModel) { + model.IPv4PrefixLength = nil + model.IPv4Prefix = nil + model.IPv4DnsNameServers = nil model.IPv4Gateway = nil }), }, { - description: "use ipv6 dns servers, prefix, gateway and prefix length", + description: "use with prefixLength", + flagValues: fixtureFlagValuesWithPrefixLength(), + isValid: true, + expectedModel: fixtureInputModelWithPrefixLength(), + }, + { + description: "use with prefixLength only ipv4", + flagValues: fixtureFlagValuesWithPrefixLength(func(flagValues map[string]string) { + delete(flagValues, ipv6GatewayFlag) + delete(flagValues, ipv6PrefixFlag) + delete(flagValues, ipv6PrefixLengthFlag) + delete(flagValues, ipv6DnsNameServersFlag) + }), + isValid: true, + expectedModel: fixtureInputModelWithPrefixLength(func(model *inputModel) { + model.IPv6PrefixLength = nil + model.IPv6Prefix = nil + model.IPv6DnsNameServers = nil + model.IPv6Gateway = nil + }), + }, + { + description: "use with prefixLength only ipv6", + flagValues: fixtureFlagValuesWithPrefixLength(func(flagValues map[string]string) { + delete(flagValues, ipv4GatewayFlag) + delete(flagValues, ipv4PrefixFlag) + delete(flagValues, ipv4PrefixLengthFlag) + delete(flagValues, ipv4DnsNameServersFlag) + }), + isValid: true, + expectedModel: fixtureInputModelWithPrefixLength(func(model *inputModel) { + model.IPv4PrefixLength = nil + model.IPv4Prefix = nil + model.IPv4DnsNameServers = nil + model.IPv4Gateway = nil + }), + }, + { + description: "use ipv4 gateway nil", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[ipv6DnsNameServersFlag] = "2001:4860:4860::8888" - flagValues[ipv6PrefixLengthFlag] = "25" - flagValues[ipv6PrefixFlag] = "2001:4860:4860::8888" - flagValues[ipv6GatewayFlag] = "2001:4860:4860::8888" + flagValues[noIpv4GatewayFlag] = "true" + delete(flagValues, ipv4GatewayFlag) }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.IPv6DnsNameServers = utils.Ptr([]string{"2001:4860:4860::8888"}) - model.IPv6PrefixLength = utils.Ptr(int64(25)) - model.IPv6Prefix = utils.Ptr("2001:4860:4860::8888") - model.IPv6Gateway = utils.Ptr("2001:4860:4860::8888") + model.NoIPv4Gateway = true + model.IPv4Gateway = nil }), }, { @@ -233,6 +387,72 @@ func TestParseInput(t *testing.T) { model.IPv6Gateway = nil }), }, + { + description: "ipv4 prefix length and prefix conflict", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4PrefixFlag] = testIPv4Prefix + flagValues[ipv4PrefixLengthFlag] = strconv.FormatInt(testIPv4PrefixLength, 10) + }), + isValid: false, + expectedModel: nil, + }, + { + description: "ipv6 prefix length and prefix conflict", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv6PrefixFlag] = testIPv6Prefix + flagValues[ipv6PrefixLengthFlag] = strconv.FormatInt(testIPv6PrefixLength, 10) + }), + isValid: false, + expectedModel: nil, + }, + { + description: "ipv4 nameserver with missing prefix or prefix length", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4DnsNameServersFlag] = strings.Join(testIPv4NameServers, ",") + }), + isValid: false, + expectedModel: nil, + }, + { + description: "ipv6 nameserver with missing prefix or prefix length", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv6DnsNameServersFlag] = strings.Join(testIPv6NameServers, ",") + }), + isValid: false, + expectedModel: nil, + }, + { + description: "ipv4 gateway and no-gateway flag conflict", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4GatewayFlag] = testIPv4Gateway + flagValues[noIpv4GatewayFlag] = "true" + }), + isValid: false, + }, + { + description: "ipv6 gateway and no-gateway flag conflict", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv6GatewayFlag] = testIPv4Gateway + flagValues[noIpv6GatewayFlag] = "true" + }), + isValid: false, + }, + { + description: "ipv4 gateway and prefixLength flag conflict", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv4GatewayFlag] = testIPv4Gateway + flagValues[ipv4PrefixLengthFlag] = strconv.FormatInt(testIPv4PrefixLength, 10) + }), + isValid: false, + }, + { + description: "ipv6 gateway and prefixLength flag conflict", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ipv6GatewayFlag] = testIPv6Gateway + flagValues[ipv6PrefixLengthFlag] = strconv.FormatInt(testIPv6PrefixLength, 10) + }), + isValid: false, + }, { description: "non-routed network", flagValues: fixtureFlagValues(func(flagValues map[string]string) { @@ -253,50 +473,29 @@ func TestParseInput(t *testing.T) { }), isValid: true, }, + { + description: "routing-table id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdFlag] = "invalid-uuid" + }), + expectedModel: nil, + isValid: false, + }, + { + description: "routing-table id not set", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, routingTableIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.RoutingTableID = nil + }), + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -318,107 +517,115 @@ func TestBuildRequest(t *testing.T) { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - Name: utils.Ptr("example-network-name"), + Name: utils.Ptr(testNetworkName), }, expectedRequest: fixtureRequiredRequest(), }, + { + description: "use prefix length", + model: fixtureInputModelWithPrefixLength(), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateNetworkRequest) { + *request = (*request).CreateNetworkPayload(fixturePayloadWithPrefixLength()) + }), + }, + { + description: "use prefix", + model: fixtureInputModelWithPrefix(), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateNetworkRequest) { + *request = (*request).CreateNetworkPayload(fixturePayloadWithPrefix()) + }), + }, { description: "non-routed network", model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - Name: utils.Ptr("example-network-name"), + Name: utils.Ptr(testNetworkName), NonRouted: true, }, - expectedRequest: testClient.CreateNetwork(testCtx, testProjectId).CreateNetworkPayload(iaas.CreateNetworkPayload{ - Name: utils.Ptr("example-network-name"), + expectedRequest: testClient.CreateNetwork(testCtx, testProjectId, testRegion).CreateNetworkPayload(iaas.CreateNetworkPayload{ + Name: utils.Ptr(testNetworkName), Routed: utils.Ptr(false), }), }, { - description: "use dns servers, prefix, gateway and prefix length", + description: "network with routing-table id attached", model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - IPv4DnsNameServers: utils.Ptr([]string{"1.1.1.1"}), - IPv4PrefixLength: utils.Ptr(int64(25)), - IPv4Prefix: utils.Ptr("10.1.2.0/24"), - IPv4Gateway: utils.Ptr("10.1.2.3"), + Name: utils.Ptr(testNetworkName), + RoutingTableID: utils.Ptr(testRoutingTableId), }, - expectedRequest: testClient.CreateNetwork(testCtx, testProjectId).CreateNetworkPayload(iaas.CreateNetworkPayload{ - AddressFamily: &iaas.CreateNetworkAddressFamily{ - Ipv4: &iaas.CreateNetworkIPv4Body{ - Nameservers: utils.Ptr([]string{"1.1.1.1"}), - PrefixLength: utils.Ptr(int64(25)), - Prefix: utils.Ptr("10.1.2.0/24"), - Gateway: iaas.NewNullableString(utils.Ptr("10.1.2.3")), - }, - }, - Routed: utils.Ptr(true), + expectedRequest: testClient.CreateNetwork(testCtx, testProjectId, testRegion).CreateNetworkPayload(iaas.CreateNetworkPayload{ + Name: utils.Ptr(testNetworkName), + RoutingTableId: utils.Ptr(testRoutingTableId), + Routed: utils.Ptr(true), }), }, { - description: "use ipv4 gateway nil", + description: "use ipv4 dns servers and prefix length", model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - NoIPv4Gateway: true, - IPv4Gateway: nil, + IPv4DnsNameServers: utils.Ptr([]string{"1.1.1.1"}), + IPv4PrefixLength: utils.Ptr(int64(25)), }, - expectedRequest: testClient.CreateNetwork(testCtx, testProjectId).CreateNetworkPayload(iaas.CreateNetworkPayload{ - AddressFamily: &iaas.CreateNetworkAddressFamily{ - Ipv4: &iaas.CreateNetworkIPv4Body{ - Gateway: iaas.NewNullableString(nil), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateNetworkRequest) { + *request = (*request).CreateNetworkPayload(iaas.CreateNetworkPayload{ + Ipv4: &iaas.CreateNetworkIPv4{ + CreateNetworkIPv4WithPrefixLength: &iaas.CreateNetworkIPv4WithPrefixLength{ + Nameservers: utils.Ptr([]string{"1.1.1.1"}), + PrefixLength: utils.Ptr(int64(25)), + }, }, - }, - Routed: utils.Ptr(true), + Routed: utils.Ptr(true), + }) }), }, { - description: "use ipv6 dns servers, prefix, gateway and prefix length", - model: &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ - ProjectId: testProjectId, - Verbosity: globalflags.VerbosityDefault, - }, - IPv6DnsNameServers: utils.Ptr([]string{"2001:4860:4860::8888"}), - IPv6PrefixLength: utils.Ptr(int64(25)), - IPv6Prefix: utils.Ptr("2001:4860:4860::8888"), - IPv6Gateway: utils.Ptr("2001:4860:4860::8888"), - }, - expectedRequest: testClient.CreateNetwork(testCtx, testProjectId).CreateNetworkPayload(iaas.CreateNetworkPayload{ - AddressFamily: &iaas.CreateNetworkAddressFamily{ - Ipv6: &iaas.CreateNetworkIPv6Body{ - Nameservers: utils.Ptr([]string{"2001:4860:4860::8888"}), - PrefixLength: utils.Ptr(int64(25)), - Prefix: utils.Ptr("2001:4860:4860::8888"), - Gateway: iaas.NewNullableString(utils.Ptr("2001:4860:4860::8888")), - }, - }, - Routed: utils.Ptr(true), + description: "use prefix with no gateway", + model: fixtureInputModelWithPrefix(func(model *inputModel) { + model.NoIPv4Gateway = true + model.NoIPv6Gateway = true + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiCreateNetworkRequest) { + *request = (*request).CreateNetworkPayload( + fixturePayloadWithPrefix(func(payload *iaas.CreateNetworkPayload) { + payload.Ipv4.CreateNetworkIPv4WithPrefix.Gateway = iaas.NewNullableString(nil) + payload.Ipv6.CreateNetworkIPv6WithPrefix.Gateway = iaas.NewNullableString(nil) + }), + ) }), }, { - description: "use ipv6 gateway nil", + description: "use ipv6 dns servers, prefix and gateway", model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, - NoIPv6Gateway: true, - IPv6Gateway: nil, + IPv6DnsNameServers: utils.Ptr([]string{"2001:4860:4860::8888"}), + IPv6Prefix: utils.Ptr("2001:4860:4860::8888"), + IPv6Gateway: utils.Ptr("2001:4860:4860::8888"), }, - expectedRequest: testClient.CreateNetwork(testCtx, testProjectId).CreateNetworkPayload(iaas.CreateNetworkPayload{ - AddressFamily: &iaas.CreateNetworkAddressFamily{ - Ipv6: &iaas.CreateNetworkIPv6Body{ - Gateway: iaas.NewNullableString(nil), + expectedRequest: testClient.CreateNetwork(testCtx, testProjectId, testRegion).CreateNetworkPayload(iaas.CreateNetworkPayload{ + Ipv6: &iaas.CreateNetworkIPv6{ + CreateNetworkIPv6WithPrefix: &iaas.CreateNetworkIPv6WithPrefix{ + Nameservers: utils.Ptr([]string{"2001:4860:4860::8888"}), + Prefix: utils.Ptr("2001:4860:4860::8888"), + Gateway: iaas.NewNullableString(utils.Ptr("2001:4860:4860::8888")), }, }, Routed: utils.Ptr(true), @@ -429,7 +636,7 @@ func TestBuildRequest(t *testing.T) { t.Run(tt.description, func(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) - diff := cmp.Diff(request, tt.expectedRequest, + diff := cmp.Diff(tt.expectedRequest, request, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), cmp.AllowUnexported(iaas.NullableString{}), @@ -467,7 +674,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.network); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network/delete/delete.go b/internal/cmd/network/delete/delete.go index c1608151e..3b3cc2177 100644 --- a/internal/cmd/network/delete/delete.go +++ b/internal/cmd/network/delete/delete.go @@ -4,6 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +18,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -28,7 +31,7 @@ type inputModel struct { NetworkId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", networkIdArg), Short: "Deletes a network", @@ -45,31 +48,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, model.NetworkId) + networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, model.Region, model.NetworkId) if err != nil { - p.Debug(print.ErrorLevel, "get network name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get network name: %v", err) networkLabel = model.NetworkId } else if networkLabel == "" { networkLabel = model.NetworkId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete network %q?", networkLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete network %q?", networkLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,20 +82,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting network") - _, err = wait.DeleteNetworkWaitHandler(ctx, apiClient, model.ProjectId, model.NetworkId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting network", func() error { + _, err = wait.DeleteNetworkWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.NetworkId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for network deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s network %q\n", operationState, networkLabel) + params.Printer.Info("%s network %q\n", operationState, networkLabel) return nil }, } @@ -114,18 +115,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu NetworkId: networkId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteNetworkRequest { - return apiClient.DeleteNetwork(ctx, model.ProjectId, model.NetworkId) + return apiClient.DeleteNetwork(ctx, model.ProjectId, model.Region, model.NetworkId) } diff --git a/internal/cmd/network/delete/delete_test.go b/internal/cmd/network/delete/delete_test.go index 726364502..76627b697 100644 --- a/internal/cmd/network/delete/delete_test.go +++ b/internal/cmd/network/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, NetworkId: testNetworkId, } @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteNetworkRequest)) iaas.ApiDeleteNetworkRequest { - request := testClient.DeleteNetwork(testCtx, testProjectId, testNetworkId) + request := testClient.DeleteNetwork(testCtx, testProjectId, testRegion, testNetworkId) for _, mod := range mods { mod(&request) } @@ -101,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/network/describe/describe.go b/internal/cmd/network/describe/describe.go index 13eb11133..4f2a20319 100644 --- a/internal/cmd/network/describe/describe.go +++ b/internal/cmd/network/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -29,7 +30,7 @@ type inputModel struct { NetworkId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", networkIdArg), Short: "Shows details of a network", @@ -47,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read network: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -84,74 +85,62 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu NetworkId: networkId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetNetworkRequest { - return apiClient.GetNetwork(ctx, model.ProjectId, model.NetworkId) + return apiClient.GetNetwork(ctx, model.ProjectId, model.Region, model.NetworkId) } func outputResult(p *print.Printer, outputFormat string, network *iaas.Network) error { if network == nil { return fmt.Errorf("network cannot be nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(network, "", " ") - if err != nil { - return fmt.Errorf("marshal network: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(network, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - var ipv4nameservers []string - if network.Nameservers != nil { - ipv4nameservers = append(ipv4nameservers, *network.Nameservers...) - } - - var ipv4prefixes []string - if network.Prefixes != nil { - ipv4prefixes = append(ipv4prefixes, *network.Prefixes...) - } - - var ipv6nameservers []string - if network.NameserversV6 != nil { - ipv6nameservers = append(ipv6nameservers, *network.NameserversV6...) + return p.OutputResult(outputFormat, network, func() error { + // IPv4 + var ipv4Nameservers, ipv4Prefixes []string + var publicIp, ipv4Gateway *string + if ipv4 := network.Ipv4; ipv4 != nil { + if ipv4.Nameservers != nil { + ipv4Nameservers = append(ipv4Nameservers, *ipv4.Nameservers...) + } + if ipv4.Prefixes != nil { + ipv4Prefixes = append(ipv4Prefixes, *ipv4.Prefixes...) + } + if ipv4.PublicIp != nil { + publicIp = ipv4.PublicIp + } + if ipv4.Gateway != nil && ipv4.Gateway.IsSet() { + ipv4Gateway = ipv4.Gateway.Get() + } } - var ipv6prefixes []string - if network.PrefixesV6 != nil { - ipv6prefixes = append(ipv6prefixes, *network.PrefixesV6...) + // IPv6 + var ipv6Nameservers, ipv6Prefixes []string + var ipv6Gateway *string + if ipv6 := network.Ipv6; ipv6 != nil { + if ipv6.Nameservers != nil { + ipv6Nameservers = append(ipv6Nameservers, *ipv6.Nameservers...) + } + if ipv6.Prefixes != nil { + ipv6Prefixes = append(ipv6Prefixes, *ipv6.Prefixes...) + } + if ipv6.Gateway != nil && ipv6.Gateway.IsSet() { + ipv6Gateway = ipv6.Gateway.Get() + } } table := tables.NewTable() - table.AddRow("ID", utils.PtrString(network.NetworkId)) + table.AddRow("ID", utils.PtrString(network.Id)) table.AddSeparator() table.AddRow("NAME", utils.PtrString(network.Name)) table.AddSeparator() - table.AddRow("STATE", utils.PtrString(network.State)) + table.AddRow("STATE", utils.PtrString(network.Status)) table.AddSeparator() - if network.PublicIp != nil { - table.AddRow("PUBLIC IP", *network.PublicIp) + if publicIp != nil { + table.AddRow("PUBLIC IP", *publicIp) table.AddSeparator() } @@ -163,33 +152,38 @@ func outputResult(p *print.Printer, outputFormat string, network *iaas.Network) table.AddRow("ROUTED", routed) table.AddSeparator() - if network.Gateway != nil { - table.AddRow("IPv4 GATEWAY", *network.Gateway.Get()) + if network.RoutingTableId != nil { + table.AddRow("ROUTING TABLE ID", utils.PtrString(network.RoutingTableId)) table.AddSeparator() } - if len(ipv4nameservers) > 0 { - table.AddRow("IPv4 NAME SERVERS", strings.Join(ipv4nameservers, ", ")) + if ipv4Gateway != nil { + table.AddRow("IPv4 GATEWAY", *ipv4Gateway) + table.AddSeparator() + } + + if len(ipv4Nameservers) > 0 { + table.AddRow("IPv4 NAME SERVERS", strings.Join(ipv4Nameservers, ", ")) } table.AddSeparator() - if len(ipv4prefixes) > 0 { - table.AddRow("IPv4 PREFIXES", strings.Join(ipv4prefixes, ", ")) + if len(ipv4Prefixes) > 0 { + table.AddRow("IPv4 PREFIXES", strings.Join(ipv4Prefixes, ", ")) } table.AddSeparator() - if network.Gatewayv6 != nil { - table.AddRow("IPv6 GATEWAY", *network.Gatewayv6.Get()) + if ipv6Gateway != nil { + table.AddRow("IPv6 GATEWAY", *ipv6Gateway) table.AddSeparator() } - if len(ipv6nameservers) > 0 { - table.AddRow("IPv6 NAME SERVERS", strings.Join(ipv6nameservers, ", ")) + if len(ipv6Nameservers) > 0 { + table.AddRow("IPv6 NAME SERVERS", strings.Join(ipv6Nameservers, ", ")) + table.AddSeparator() } - table.AddSeparator() - if len(ipv6prefixes) > 0 { - table.AddRow("IPv6 PREFIXES", strings.Join(ipv6prefixes, ", ")) + if len(ipv6Prefixes) > 0 { + table.AddRow("IPv6 PREFIXES", strings.Join(ipv6Prefixes, ", ")) + table.AddSeparator() } - table.AddSeparator() if network.Labels != nil && len(*network.Labels) > 0 { var labels []string for key, value := range *network.Labels { @@ -204,5 +198,5 @@ func outputResult(p *print.Printer, outputFormat string, network *iaas.Network) return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/network/describe/describe_test.go b/internal/cmd/network/describe/describe_test.go index 451b518b3..14fa618e4 100644 --- a/internal/cmd/network/describe/describe_test.go +++ b/internal/cmd/network/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +39,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, NetworkId: testNetworkId, } @@ -57,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetNetworkRequest)) iaas.ApiGetNetworkRequest { - request := testClient.GetNetwork(testCtx, testProjectId, testNetworkId) + request := testClient.GetNetwork(testCtx, testProjectId, testRegion, testNetworkId) for _, mod := range mods { mod(&request) } @@ -101,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +144,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -239,9 +199,27 @@ func TestOutputResult(t *testing.T) { }, wantErr: false, }, + { + name: "set empty ipv4", + args: args{ + network: &iaas.Network{ + Ipv4: &iaas.NetworkIPv4{}, + }, + }, + wantErr: false, + }, + { + name: "set empty ipv6", + args: args{ + network: &iaas.Network{ + Ipv6: &iaas.NetworkIPv6{}, + }, + }, + wantErr: false, + }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.network); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network/list/list.go b/internal/cmd/network/list/list.go index 8b5577685..6bc0a8b67 100644 --- a/internal/cmd/network/list/list.go +++ b/internal/cmd/network/list/list.go @@ -2,10 +2,12 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -32,7 +33,7 @@ type inputModel struct { LabelSelector *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all networks of a project", @@ -56,15 +57,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit network list --label-selector xxx", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -77,14 +78,14 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId } - p.Info("No networks found for project %q\n", projectLabel) + params.Printer.Info("No networks found for project %q\n", projectLabel) return nil } @@ -94,7 +95,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -106,7 +107,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(labelSelectorFlag, "", "Filter by label") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -126,20 +127,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListNetworksRequest { - req := apiClient.ListNetworks(ctx, model.ProjectId) + req := apiClient.ListNetworks(ctx, model.ProjectId, model.Region) if model.LabelSelector != nil { req = req.LabelSelector(*model.LabelSelector) } @@ -147,48 +140,35 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat string, networks []iaas.Network) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(networks, "", " ") - if err != nil { - return fmt.Errorf("marshal network: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(networks, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal network: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, networks, func() error { table := tables.NewTable() - table.SetHeader("ID", "NAME", "STATUS", "PUBLIC IP", "PREFIXES", "ROUTED") + table.SetHeader("ID", "NAME", "STATUS", "PUBLIC IP", "PREFIXES", "ROUTED", "ROUTING TABLE ID") for _, network := range networks { - publicIp := utils.PtrString(network.PublicIp) + var publicIp, prefixes string + if ipv4 := network.Ipv4; ipv4 != nil { + publicIp = utils.PtrString(ipv4.PublicIp) + prefixes = utils.JoinStringPtr(ipv4.Prefixes, ", ") + } routed := false if network.Routed != nil { routed = *network.Routed } - prefixes := utils.JoinStringPtr(network.Prefixes, ", ") table.AddRow( - utils.PtrString(network.NetworkId), + utils.PtrString(network.Id), utils.PtrString(network.Name), - utils.PtrString(network.State), + utils.PtrString(network.Status), publicIp, prefixes, routed, + utils.PtrString(network.RoutingTableId), ) table.AddSeparator() } p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/network/list/list_test.go b/internal/cmd/network/list/list_test.go index 9bc47dfb5..67e90a2b4 100644 --- a/internal/cmd/network/list/list_test.go +++ b/internal/cmd/network/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,18 +17,22 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" + testLabelSelector = "foo=bar" +) type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &iaas.APIClient{} var testProjectId = uuid.NewString() -var testLabelSelector = "foo=bar" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", labelSelectorFlag: testLabelSelector, } @@ -40,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), LabelSelector: utils.Ptr(testLabelSelector), @@ -51,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListNetworksRequest)) iaas.ApiListNetworksRequest { - request := testClient.ListNetworks(testCtx, testProjectId) + request := testClient.ListNetworks(testCtx, testProjectId, testRegion) request = request.LabelSelector(testLabelSelector) for _, mod := range mods { mod(&request) @@ -62,6 +70,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListNetworksRequest)) iaas.Api func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -85,21 +94,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -131,46 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -229,7 +199,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.networks); (err != nil) != tt.wantErr { diff --git a/internal/cmd/network/network.go b/internal/cmd/network/network.go index b95a496c7..eb7c6ece7 100644 --- a/internal/cmd/network/network.go +++ b/internal/cmd/network/network.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/network/list" "github.com/stackitcloud/stackit-cli/internal/cmd/network/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "network", Short: "Provides functionality for networks", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/network/update/update.go b/internal/cmd/network/update/update.go index 50be6c698..add37c6fa 100644 --- a/internal/cmd/network/update/update.go +++ b/internal/cmd/network/update/update.go @@ -4,6 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,8 +19,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -30,6 +33,7 @@ const ( ipv6GatewayFlag = "ipv6-gateway" noIpv4GatewayFlag = "no-ipv4-gateway" noIpv6GatewayFlag = "no-ipv6-gateway" + routingTableIdFlag = "routing-table-id" labelFlag = "labels" ) @@ -43,10 +47,11 @@ type inputModel struct { IPv6Gateway *string NoIPv4Gateway bool NoIPv6Gateway bool + RoutingTableId *string Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", networkIdArg), Short: "Updates a network", @@ -69,34 +74,36 @@ func NewCmd(p *print.Printer) *cobra.Command { `Update IPv6 network with ID "xxx" with new name "network-1-new", new gateway and new DNS name servers`, `$ stackit network update xxx --name network-1-new --ipv6-dns-name-servers "2001:4860:4860::8888" --ipv6-gateway "2001:4860:4860::8888"`, ), + examples.NewExample( + `Update network with ID "xxx" with new routing-table id "xxx"`, + `$ stackit network update xxx --routing-table-id xxx`, + ), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, model.NetworkId) + networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, model.Region, model.NetworkId) if err != nil { - p.Debug(print.ErrorLevel, "get network name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get network name: %v", err) networkLabel = model.NetworkId } else if networkLabel == "" { networkLabel = model.NetworkId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update network %q?", networkLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update network %q?", networkLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -109,20 +116,19 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating network") - _, err = wait.UpdateNetworkWaitHandler(ctx, apiClient, model.ProjectId, networkId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating network", func() error { + _, err = wait.UpdateNetworkWaitHandler(ctx, apiClient, model.ProjectId, model.Region, networkId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for network update: %w", err) } - s.Stop() } - operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Info("%s network %q\n", operationState, networkLabel) + params.Printer.Info("%s network %q\n", operationState, networkLabel) return nil }, } @@ -138,6 +144,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(ipv6GatewayFlag, "", "The IPv6 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway") cmd.Flags().Bool(noIpv4GatewayFlag, false, "If set to true, the network doesn't have an IPv4 gateway") cmd.Flags().Bool(noIpv6GatewayFlag, false, "If set to true, the network doesn't have an IPv6 gateway") + cmd.Flags().Var(flags.UUIDFlag(), routingTableIdFlag, "The ID of the routing-table for the network") cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network. E.g. '--labels key1=value1,key2=value2,...'") } @@ -159,65 +166,49 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu IPv6Gateway: flags.FlagToStringPointer(p, cmd, ipv6GatewayFlag), NoIPv4Gateway: flags.FlagToBoolValue(p, cmd, noIpv4GatewayFlag), NoIPv6Gateway: flags.FlagToBoolValue(p, cmd, noIpv6GatewayFlag), + RoutingTableId: flags.FlagToStringPointer(p, cmd, routingTableIdFlag), Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiPartialUpdateNetworkRequest { - req := apiClient.PartialUpdateNetwork(ctx, model.ProjectId, model.NetworkId) - addressFamily := &iaas.UpdateNetworkAddressFamily{} - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } + req := apiClient.PartialUpdateNetwork(ctx, model.ProjectId, model.Region, model.NetworkId) + var payloadIPv4 *iaas.UpdateNetworkIPv4Body + var payloadIPv6 *iaas.UpdateNetworkIPv6Body if model.IPv6DnsNameServers != nil || model.NoIPv6Gateway || model.IPv6Gateway != nil { - addressFamily.Ipv6 = &iaas.UpdateNetworkIPv6Body{ + payloadIPv6 = &iaas.UpdateNetworkIPv6Body{ Nameservers: model.IPv6DnsNameServers, } if model.NoIPv6Gateway { - addressFamily.Ipv6.Gateway = iaas.NewNullableString(nil) + payloadIPv6.Gateway = iaas.NewNullableString(nil) } else if model.IPv6Gateway != nil { - addressFamily.Ipv6.Gateway = iaas.NewNullableString(model.IPv6Gateway) + payloadIPv6.Gateway = iaas.NewNullableString(model.IPv6Gateway) } } if model.IPv4DnsNameServers != nil || model.NoIPv4Gateway || model.IPv4Gateway != nil { - addressFamily.Ipv4 = &iaas.UpdateNetworkIPv4Body{ + payloadIPv4 = &iaas.UpdateNetworkIPv4Body{ Nameservers: model.IPv4DnsNameServers, } if model.NoIPv4Gateway { - addressFamily.Ipv4.Gateway = iaas.NewNullableString(nil) + payloadIPv4.Gateway = iaas.NewNullableString(nil) } else if model.IPv4Gateway != nil { - addressFamily.Ipv4.Gateway = iaas.NewNullableString(model.IPv4Gateway) + payloadIPv4.Gateway = iaas.NewNullableString(model.IPv4Gateway) } } payload := iaas.PartialUpdateNetworkPayload{ - Name: model.Name, - Labels: labelsMap, - } - - if addressFamily.Ipv4 != nil || addressFamily.Ipv6 != nil { - payload.AddressFamily = addressFamily + Name: model.Name, + Ipv4: payloadIPv4, + Ipv6: payloadIPv6, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), + RoutingTableId: model.RoutingTableId, } return req.PartialUpdateNetworkPayload(payload) diff --git a/internal/cmd/network/update/update_test.go b/internal/cmd/network/update/update_test.go index 7a1b243c5..628b93190 100644 --- a/internal/cmd/network/update/update_test.go +++ b/internal/cmd/network/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -23,6 +27,7 @@ var testClient = &iaas.APIClient{} var testProjectId = uuid.NewString() var testNetworkId = uuid.NewString() +var testRoutingTableId = uuid.NewString() func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -36,13 +41,16 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + nameFlag: "example-network-name", - projectIdFlag: testProjectId, ipv4DnsNameServersFlag: "1.1.1.0,1.1.2.0", ipv4GatewayFlag: "10.1.2.3", ipv6DnsNameServersFlag: "2001:4860:4860::8888,2001:4860:4860::8844", ipv6GatewayFlag: "2001:4860:4860::8888", labelFlag: "key=value", + routingTableIdFlag: testRoutingTableId, } for _, mod := range mods { mod(flagValues) @@ -55,6 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, Name: utils.Ptr("example-network-name"), NetworkId: testNetworkId, @@ -65,6 +74,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { Labels: utils.Ptr(map[string]string{ "key": "value", }), + RoutingTableId: utils.Ptr(testRoutingTableId), } for _, mod := range mods { mod(model) @@ -73,7 +83,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiPartialUpdateNetworkRequest)) iaas.ApiPartialUpdateNetworkRequest { - request := testClient.PartialUpdateNetwork(testCtx, testProjectId, testNetworkId) + request := testClient.PartialUpdateNetwork(testCtx, testProjectId, testRegion, testNetworkId) request = request.PartialUpdateNetworkPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -87,16 +97,15 @@ func fixturePayload(mods ...func(payload *iaas.PartialUpdateNetworkPayload)) iaa Labels: utils.Ptr(map[string]interface{}{ "key": "value", }), - AddressFamily: &iaas.UpdateNetworkAddressFamily{ - Ipv4: &iaas.UpdateNetworkIPv4Body{ - Nameservers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}), - Gateway: iaas.NewNullableString(utils.Ptr("10.1.2.3")), - }, - Ipv6: &iaas.UpdateNetworkIPv6Body{ - Nameservers: utils.Ptr([]string{"2001:4860:4860::8888", "2001:4860:4860::8844"}), - Gateway: iaas.NewNullableString(utils.Ptr("2001:4860:4860::8888")), - }, + Ipv4: &iaas.UpdateNetworkIPv4Body{ + Nameservers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}), + Gateway: iaas.NewNullableString(utils.Ptr("10.1.2.3")), }, + Ipv6: &iaas.UpdateNetworkIPv6Body{ + Nameservers: utils.Ptr([]string{"2001:4860:4860::8888", "2001:4860:4860::8844"}), + Gateway: iaas.NewNullableString(utils.Ptr("2001:4860:4860::8888")), + }, + RoutingTableId: utils.Ptr(testRoutingTableId), } for _, mod := range mods { mod(&payload) @@ -141,7 +150,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -149,7 +158,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -157,7 +166,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -236,12 +245,21 @@ func TestParseInput(t *testing.T) { }), isValid: true, }, + { + description: "route-table id wrong format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[routingTableIdFlag] = "wrong-format" + }), + expectedModel: nil, + isValid: false, + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/object-storage/bucket/bucket.go b/internal/cmd/object-storage/bucket/bucket.go index 701fc0934..0f8ab39a3 100644 --- a/internal/cmd/object-storage/bucket/bucket.go +++ b/internal/cmd/object-storage/bucket/bucket.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/bucket/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/bucket/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "bucket", Short: "Provides functionality for Object Storage buckets", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/object-storage/bucket/create/create.go b/internal/cmd/object-storage/bucket/create/create.go index e96a4206c..577d244d0 100644 --- a/internal/cmd/object-storage/bucket/create/create.go +++ b/internal/cmd/object-storage/bucket/create/create.go @@ -2,10 +2,11 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,20 +17,22 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/wait" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api/wait" ) const ( - bucketNameArg = "BUCKET_NAME" + bucketNameArg = "BUCKET_NAME" + objectLockEnabledFlag = "object-lock-enabled" ) type inputModel struct { *globalflags.GlobalFlagModel - BucketName string + BucketName string + ObjectLockEnabled bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("create %s", bucketNameArg), Short: "Creates an Object Storage bucket", @@ -39,30 +42,31 @@ func NewCmd(p *print.Printer) *cobra.Command { examples.NewExample( `Create an Object Storage bucket with name "my-bucket"`, "$ stackit object-storage bucket create my-bucket"), + examples.NewExample( + `Create an Object Storage bucket with enabled object-lock`, + `$ stackit object-storage bucket create my-bucket --object-lock-enabled`), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create bucket %q? (This cannot be undone)", model.BucketName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create bucket %q? (This cannot be undone)", model.BucketName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Check if the project is enabled before trying to create - enabled, err := utils.ProjectEnabled(ctx, apiClient, model.ProjectId, model.Region) + enabled, err := utils.ProjectEnabled(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region) if err != nil { return fmt.Errorf("check if Object Storage is enabled: %w", err) } @@ -81,21 +85,26 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating bucket") - _, err = wait.CreateBucketWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.BucketName).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating bucket", func() error { + _, err = wait.CreateBucketWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.BucketName).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for Object Storage bucket creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, model.BucketName, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, model.BucketName, resp) }, } + configureFlags(cmd) return cmd } +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(objectLockEnabledFlag, false, "is the object-lock enabled for the bucket") +} + func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { bucketName := inputArgs[0] @@ -105,24 +114,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu } model := inputModel{ - GlobalFlagModel: globalFlags, - BucketName: bucketName, - } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + GlobalFlagModel: globalFlags, + BucketName: bucketName, + ObjectLockEnabled: flags.FlagToBoolValue(p, cmd, objectLockEnabledFlag), } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiCreateBucketRequest { - req := apiClient.CreateBucket(ctx, model.ProjectId, model.Region, model.BucketName) + req := apiClient.DefaultAPI.CreateBucket(ctx, model.ProjectId, model.Region, model.BucketName).ObjectLockEnabled(model.ObjectLockEnabled) return req } @@ -131,29 +133,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, bucketName return fmt.Errorf("create bucket response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal Object Storage bucket: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Object Storage bucket: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } p.Outputf("%s bucket %q\n", operationState, bucketName) return nil - } + }) } diff --git a/internal/cmd/object-storage/bucket/create/create_test.go b/internal/cmd/object-storage/bucket/create/create_test.go index 49a089342..2460b63c7 100644 --- a/internal/cmd/object-storage/bucket/create/create_test.go +++ b/internal/cmd/object-storage/bucket/create/create_test.go @@ -4,25 +4,28 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() -var testRegion = "eu01" -var testBucketName = "my-bucket" + +const ( + testRegion = "eu01" + testBucketName = "my-bucket" +) func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -36,8 +39,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -52,7 +55,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { Verbosity: globalflags.VerbosityDefault, Region: testRegion, }, - BucketName: testBucketName, + BucketName: testBucketName, + ObjectLockEnabled: false, } for _, mod := range mods { mod(model) @@ -60,10 +64,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func fixtureRequest(mods ...func(request *objectstorage.ApiCreateBucketRequest)) objectstorage.ApiCreateBucketRequest { - request := testClient.CreateBucket(testCtx, testProjectId, testRegion, testBucketName) +func fixtureRequest(mods ...func(request objectstorage.ApiCreateBucketRequest) objectstorage.ApiCreateBucketRequest) objectstorage.ApiCreateBucketRequest { + request := testClient.DefaultAPI.CreateBucket(testCtx, testProjectId, testRegion, testBucketName).ObjectLockEnabled(false) for _, mod := range mods { - mod(&request) + request = mod(request) } return request } @@ -105,7 +109,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -113,7 +117,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -121,7 +125,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -131,58 +135,22 @@ func TestParseInput(t *testing.T) { flagValues: fixtureFlagValues(), isValid: false, }, + { + description: "enable object-lock", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[objectLockEnabledFlag] = "true" + }), + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ObjectLockEnabled = true + }), + isValid: true, + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -198,6 +166,15 @@ func TestBuildRequest(t *testing.T) { model: fixtureInputModel(), expectedRequest: fixtureRequest(), }, + { + description: "object-lock enabled", + model: fixtureInputModel(func(model *inputModel) { + model.ObjectLockEnabled = true + }), + expectedRequest: fixtureRequest(func(request objectstorage.ApiCreateBucketRequest) objectstorage.ApiCreateBucketRequest { + return request.ObjectLockEnabled(true) + }), + }, } for _, tt := range tests { @@ -205,7 +182,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { @@ -241,7 +218,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.bucketName, tt.args.createBucketResponse); (err != nil) != tt.wantErr { diff --git a/internal/cmd/object-storage/bucket/delete/delete.go b/internal/cmd/object-storage/bucket/delete/delete.go index 1c6edbf90..c6cc396f8 100644 --- a/internal/cmd/object-storage/bucket/delete/delete.go +++ b/internal/cmd/object-storage/bucket/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +15,8 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/wait" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api/wait" ) const ( @@ -26,7 +28,7 @@ type inputModel struct { BucketName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", bucketNameArg), Short: "Deletes an Object Storage bucket", @@ -39,23 +41,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete bucket %q? (This cannot be undone)", model.BucketName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete bucket %q? (This cannot be undone)", model.BucketName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -67,20 +67,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting bucket") - _, err = wait.DeleteBucketWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.BucketName).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting bucket", func() error { + _, err = wait.DeleteBucketWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.BucketName).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for Object Storage bucket deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s bucket %q\n", operationState, model.BucketName) + params.Printer.Info("%s bucket %q\n", operationState, model.BucketName) return nil }, } @@ -100,19 +100,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu BucketName: bucketName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiDeleteBucketRequest { - req := apiClient.DeleteBucket(ctx, model.ProjectId, model.Region, model.BucketName) + req := apiClient.DefaultAPI.DeleteBucket(ctx, model.ProjectId, model.Region, model.BucketName) return req } diff --git a/internal/cmd/object-storage/bucket/delete/delete_test.go b/internal/cmd/object-storage/bucket/delete/delete_test.go index be5bd0028..2bc6f06be 100644 --- a/internal/cmd/object-storage/bucket/delete/delete_test.go +++ b/internal/cmd/object-storage/bucket/delete/delete_test.go @@ -5,24 +5,24 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() -var testRegion = "eu01" -var testBucketName = "my-bucket" + +const ( + testRegion = "eu01" + testBucketName = "my-bucket" +) func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -36,8 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -61,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiDeleteBucketRequest)) objectstorage.ApiDeleteBucketRequest { - request := testClient.DeleteBucket(testCtx, testProjectId, testRegion, testBucketName) + request := testClient.DefaultAPI.DeleteBucket(testCtx, testProjectId, testRegion, testBucketName) for _, mod := range mods { mod(&request) } @@ -105,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -113,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -121,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -135,54 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -205,7 +158,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { diff --git a/internal/cmd/object-storage/bucket/describe/describe.go b/internal/cmd/object-storage/bucket/describe/describe.go index 23a9e7011..1375ff05a 100644 --- a/internal/cmd/object-storage/bucket/describe/describe.go +++ b/internal/cmd/object-storage/bucket/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,10 +13,9 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) const ( @@ -28,7 +27,7 @@ type inputModel struct { BucketName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", bucketNameArg), Short: "Shows details of an Object Storage bucket", @@ -44,12 +43,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -61,7 +60,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read Object Storage bucket: %w", err) } - return outputResult(p, model.OutputFormat, resp.Bucket) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -80,54 +79,31 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu BucketName: bucketName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiGetBucketRequest { - req := apiClient.GetBucket(ctx, model.ProjectId, model.Region, model.BucketName) + req := apiClient.DefaultAPI.GetBucket(ctx, model.ProjectId, model.Region, model.BucketName) return req } -func outputResult(p *print.Printer, outputFormat string, bucket *objectstorage.Bucket) error { - if bucket == nil { - return fmt.Errorf("bucket is empty") +func outputResult(p *print.Printer, outputFormat string, resp *objectstorage.GetBucketResponse) error { + if resp == nil { + return fmt.Errorf("response is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(bucket, "", " ") - if err != nil { - return fmt.Errorf("marshal Object Storage bucket: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(bucket, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Object Storage bucket: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp.Bucket, func() error { table := tables.NewTable() - table.AddRow("Name", utils.PtrString(bucket.Name)) + table.AddRow("Name", resp.Bucket.Name) + table.AddSeparator() + table.AddRow("Region", resp.Bucket.Region) table.AddSeparator() - table.AddRow("Region", utils.PtrString(bucket.Region)) + table.AddRow("URL (Path Style)", resp.Bucket.UrlPathStyle) table.AddSeparator() - table.AddRow("URL (Path Style)", utils.PtrString(bucket.UrlPathStyle)) + table.AddRow("URL (Virtual Hosted Style)", resp.Bucket.UrlVirtualHostedStyle) table.AddSeparator() - table.AddRow("URL (Virtual Hosted Style)", utils.PtrString(bucket.UrlVirtualHostedStyle)) + table.AddRow("Object Lock Enabled", resp.Bucket.ObjectLockEnabled) table.AddSeparator() err := table.Display(p) if err != nil { @@ -135,5 +111,5 @@ func outputResult(p *print.Printer, outputFormat string, bucket *objectstorage.B } return nil - } + }) } diff --git a/internal/cmd/object-storage/bucket/describe/describe_test.go b/internal/cmd/object-storage/bucket/describe/describe_test.go index d233fc17f..86deeef89 100644 --- a/internal/cmd/object-storage/bucket/describe/describe_test.go +++ b/internal/cmd/object-storage/bucket/describe/describe_test.go @@ -4,25 +4,28 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() -var testRegion = "eu01" -var testBucketName = "my-bucket" + +const ( + testRegion = "eu01" + testBucketName = "my-bucket" +) func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -36,8 +39,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -61,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiGetBucketRequest)) objectstorage.ApiGetBucketRequest { - request := testClient.GetBucket(testCtx, testProjectId, testRegion, testBucketName) + request := testClient.DefaultAPI.GetBucket(testCtx, testProjectId, testRegion, testBucketName) for _, mod := range mods { mod(&request) } @@ -105,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -113,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -121,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -135,54 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -205,7 +161,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { @@ -218,7 +174,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string - bucket *objectstorage.Bucket + resp *objectstorage.GetBucketResponse } tests := []struct { name string @@ -231,18 +187,18 @@ func TestOutputResult(t *testing.T) { wantErr: true, }, { - name: "set empty bucket", + name: "set empty response", args: args{ - bucket: &objectstorage.Bucket{}, + resp: &objectstorage.GetBucketResponse{}, }, wantErr: false, }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.bucket); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/object-storage/bucket/list/list.go b/internal/cmd/object-storage/bucket/list/list.go index 19466184b..352841fa6 100644 --- a/internal/cmd/object-storage/bucket/list/list.go +++ b/internal/cmd/object-storage/bucket/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,8 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" ) const ( @@ -29,7 +29,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all Object Storage buckets", @@ -46,15 +46,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 Object Storage buckets`, "$ stackit object-storage bucket list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,23 +65,20 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get Object Storage buckets: %w", err) } - if resp.Buckets == nil || len(*resp.Buckets) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No buckets found for project %s\n", projectLabel) - return nil + buckets := resp.GetBuckets() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } - buckets := *resp.Buckets // Truncate output if model.Limit != nil && len(buckets) > int(*model.Limit) { buckets = buckets[:*model.Limit] } - return outputResult(p, model.OutputFormat, buckets) + return outputResult(params.Printer, model.OutputFormat, projectLabel, buckets) }, } @@ -93,7 +90,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,55 +109,36 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiListBucketsRequest { - req := apiClient.ListBuckets(ctx, model.ProjectId, model.Region) + req := apiClient.DefaultAPI.ListBuckets(ctx, model.ProjectId, model.Region) return req } -func outputResult(p *print.Printer, outputFormat string, buckets []objectstorage.Bucket) error { +func outputResult(p *print.Printer, outputFormat, projectLabel string, buckets []objectstorage.Bucket) error { if buckets == nil { return fmt.Errorf("buckets is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(buckets, "", " ") - if err != nil { - return fmt.Errorf("marshal Object Storage bucket list: %w", err) + return p.OutputResult(outputFormat, buckets, func() error { + if len(buckets) == 0 { + p.Outputf("No buckets found for project %s\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(buckets, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Object Storage bucket list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() - table.SetHeader("NAME", "REGION", "URL (PATH STYLE)", "URL (VIRTUAL HOSTED STYLE)") + table.SetHeader("NAME", "REGION", "URL (PATH STYLE)", "URL (VIRTUAL HOSTED STYLE)", "OBJECT LOCK ENABLED") for i := range buckets { bucket := buckets[i] table.AddRow( - utils.PtrString(bucket.Name), - utils.PtrString(bucket.Region), - utils.PtrString(bucket.UrlPathStyle), - utils.PtrString(bucket.UrlVirtualHostedStyle), + bucket.Name, + bucket.Region, + bucket.UrlPathStyle, + bucket.UrlVirtualHostedStyle, + bucket.ObjectLockEnabled, ) } err := table.Display(p) @@ -169,5 +147,5 @@ func outputResult(p *print.Printer, outputFormat string, buckets []objectstorage } return nil - } + }) } diff --git a/internal/cmd/object-storage/bucket/list/list_test.go b/internal/cmd/object-storage/bucket/list/list_test.go index 31664679f..c2916bfb3 100644 --- a/internal/cmd/object-storage/bucket/list/list_test.go +++ b/internal/cmd/object-storage/bucket/list/list_test.go @@ -4,32 +4,31 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() var testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -53,7 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiListBucketsRequest)) objectstorage.ApiListBucketsRequest { - request := testClient.ListBuckets(testCtx, testProjectId, testRegion) + request := testClient.DefaultAPI.ListBuckets(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -63,6 +62,7 @@ func fixtureRequest(mods ...func(request *objectstorage.ApiListBucketsRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -81,21 +81,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -117,48 +117,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -181,7 +140,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { @@ -194,6 +153,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string buckets []objectstorage.Bucket } tests := []struct { @@ -215,10 +175,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.buckets); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.buckets); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/object-storage/compliance-lock/compliance-lock.go b/internal/cmd/object-storage/compliance-lock/compliance-lock.go new file mode 100644 index 000000000..49df4e178 --- /dev/null +++ b/internal/cmd/object-storage/compliance-lock/compliance-lock.go @@ -0,0 +1,30 @@ +package compliancelock + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/compliance-lock/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/compliance-lock/lock" + "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/compliance-lock/unlock" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "compliance-lock", + Short: "Provides functionality to manage Object Storage compliance lock", + Long: "Provides functionality to manage Object Storage compliance lock.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(lock.NewCmd(params)) + cmd.AddCommand(unlock.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) +} diff --git a/internal/cmd/object-storage/compliance-lock/describe/describe.go b/internal/cmd/object-storage/compliance-lock/describe/describe.go new file mode 100644 index 000000000..2aa3ea34a --- /dev/null +++ b/internal/cmd/object-storage/compliance-lock/describe/describe.go @@ -0,0 +1,111 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" + objectStorageUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Describe object storage compliance lock", + Long: "Describe object storage compliance lock.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Describe object storage compliance lock`, + "$ stackit object-storage compliance-lock describe"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Check if the project is enabled before trying to describe + enabled, err := objectStorageUtils.ProjectEnabled(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region) + if err != nil { + return fmt.Errorf("check if Object Storage is enabled: %w", err) + } + if !enabled { + return &errors.ServiceDisabledError{ + Service: "object-storage", + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get object storage compliance lock: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiGetComplianceLockRequest { + req := apiClient.DefaultAPI.GetComplianceLock(ctx, model.ProjectId, model.Region) + return req +} + +func outputResult(p *print.Printer, outputFormat string, resp *objectstorage.ComplianceLockResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil { + return fmt.Errorf("response is empty") + } + + table := tables.NewTable() + table.AddRow("PROJECT ID", resp.Project) + table.AddSeparator() + table.AddRow("MAX RETENTION DAYS", resp.MaxRetentionDays) + table.AddSeparator() + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/object-storage/compliance-lock/describe/describe_test.go b/internal/cmd/object-storage/compliance-lock/describe/describe_test.go new file mode 100644 index 000000000..1c92d7cdf --- /dev/null +++ b/internal/cmd/object-storage/compliance-lock/describe/describe_test.go @@ -0,0 +1,179 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +const ( + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *objectstorage.ApiGetComplianceLockRequest)) objectstorage.ApiGetComplianceLockRequest { + request := testClient.DefaultAPI.GetComplianceLock(testCtx, testProjectId, testRegion) + 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: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest objectstorage.ApiGetComplianceLockRequest + }{ + { + 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, objectstorage.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + complianceLock *objectstorage.ComplianceLockResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: print.PrettyOutputFormat, + }, + wantErr: true, + }, + { + name: "set empty compliance lock", + args: args{ + outputFormat: print.PrettyOutputFormat, + complianceLock: &objectstorage.ComplianceLockResponse{}, + }, + wantErr: false, + }, + { + name: "set filled lock", + args: args{ + outputFormat: print.PrettyOutputFormat, + complianceLock: &objectstorage.ComplianceLockResponse{ + Project: uuid.New().String(), + MaxRetentionDays: int32(42), + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.complianceLock); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/object-storage/compliance-lock/lock/lock.go b/internal/cmd/object-storage/compliance-lock/lock/lock.go new file mode 100644 index 000000000..e179f7c26 --- /dev/null +++ b/internal/cmd/object-storage/compliance-lock/lock/lock.go @@ -0,0 +1,116 @@ +package lock + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/utils" + + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "lock", + Short: "Create object storage compliance lock", + Long: "Create object storage compliance lock.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create object storage compliance lock`, + "$ stackit object-storage compliance-lock lock"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to create object storage compliance-lock for project %s?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Check if the project is enabled before trying to create + enabled, err := utils.ProjectEnabled(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region) + if err != nil { + return fmt.Errorf("check if Object Storage is enabled: %w", err) + } + if !enabled { + return &errors.ServiceDisabledError{ + Service: "object-storage", + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create object storage compliance lock: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiCreateComplianceLockRequest { + req := apiClient.DefaultAPI.CreateComplianceLock(ctx, model.ProjectId, model.Region) + return req +} + +func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *objectstorage.ComplianceLockResponse) error { + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil { + return fmt.Errorf("create compliance lock response is empty") + } + + p.Outputf("Created object storage compliance lock for project \"%s\" with maximum retention period of %d days.\n", projectLabel, resp.MaxRetentionDays) + return nil + }) +} diff --git a/internal/cmd/object-storage/compliance-lock/lock/lock_test.go b/internal/cmd/object-storage/compliance-lock/lock/lock_test.go new file mode 100644 index 000000000..48e39cb90 --- /dev/null +++ b/internal/cmd/object-storage/compliance-lock/lock/lock_test.go @@ -0,0 +1,180 @@ +package lock + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +const ( + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *objectstorage.ApiCreateComplianceLockRequest)) objectstorage.ApiCreateComplianceLockRequest { + request := testClient.DefaultAPI.CreateComplianceLock(testCtx, testProjectId, testRegion) + 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: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest objectstorage.ApiCreateComplianceLockRequest + }{ + { + 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, objectstorage.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + projectLabel string + complianceLock *objectstorage.ComplianceLockResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{ + outputFormat: print.PrettyOutputFormat, + }, + wantErr: true, + }, + { + name: "set empty compliance lock", + args: args{ + outputFormat: print.PrettyOutputFormat, + complianceLock: &objectstorage.ComplianceLockResponse{}, + }, + wantErr: false, + }, + { + name: "set filled lock", + args: args{ + outputFormat: print.PrettyOutputFormat, + complianceLock: &objectstorage.ComplianceLockResponse{ + Project: uuid.New().String(), + MaxRetentionDays: int32(42), + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.complianceLock); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/object-storage/compliance-lock/unlock/unlock.go b/internal/cmd/object-storage/compliance-lock/unlock/unlock.go new file mode 100644 index 000000000..ad9320c2a --- /dev/null +++ b/internal/cmd/object-storage/compliance-lock/unlock/unlock.go @@ -0,0 +1,106 @@ +package unlock + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/utils" + + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "unlock", + Short: "Delete object storage compliance lock", + Long: "Delete object storage compliance lock.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Delete object storage compliance lock`, + "$ stackit object-storage compliance-lock unlock"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } else if projectLabel == "" { + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to delete object storage compliance-lock for project %s?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Check if the project is enabled before trying to create + enabled, err := utils.ProjectEnabled(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region) + if err != nil { + return fmt.Errorf("check if Object Storage is enabled: %w", err) + } + if !enabled { + return &errors.ServiceDisabledError{ + Service: "object-storage", + } + } + + // Call API + _, err = buildRequest(ctx, model, apiClient).Execute() + if err != nil { + return fmt.Errorf("delete object storage compliance lock: %w", err) + } + + params.Printer.Outputf("Deleted object storage compliance lock for project \"%s\".\n", projectLabel) + + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiDeleteComplianceLockRequest { + req := apiClient.DefaultAPI.DeleteComplianceLock(ctx, model.ProjectId, model.Region) + return req +} diff --git a/internal/cmd/object-storage/compliance-lock/unlock/unlock_test.go b/internal/cmd/object-storage/compliance-lock/unlock/unlock_test.go new file mode 100644 index 000000000..1df9eb3a9 --- /dev/null +++ b/internal/cmd/object-storage/compliance-lock/unlock/unlock_test.go @@ -0,0 +1,128 @@ +package unlock + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +const ( + testRegion = "eu01" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *objectstorage.ApiDeleteComplianceLockRequest)) objectstorage.ApiDeleteComplianceLockRequest { + request := testClient.DefaultAPI.DeleteComplianceLock(testCtx, testProjectId, testRegion) + 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: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, nil, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest objectstorage.ApiDeleteComplianceLockRequest + }{ + { + 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, objectstorage.DefaultAPIService{}), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/object-storage/credentials-group/create/create.go b/internal/cmd/object-storage/credentials-group/create/create.go index 55530ccc7..07623da46 100644 --- a/internal/cmd/object-storage/credentials-group/create/create.go +++ b/internal/cmd/object-storage/credentials-group/create/create.go @@ -2,10 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,10 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" ) const ( @@ -28,7 +27,7 @@ type inputModel struct { CredentialsGroupName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a credentials group to hold Object Storage access credentials", @@ -39,25 +38,23 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create credentials group to hold Object Storage access credentials`, "$ stackit object-storage credentials-group create --name example"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a credentials group with name %q?", model.CredentialsGroupName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a credentials group with name %q?", model.CredentialsGroupName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -67,7 +64,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Object Storage credentials group: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -81,7 +78,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -92,54 +89,29 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { CredentialsGroupName: flags.FlagToStringValue(p, cmd, credentialsGroupNameFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiCreateCredentialsGroupRequest { - req := apiClient.CreateCredentialsGroup(ctx, model.ProjectId, model.Region) + req := apiClient.DefaultAPI.CreateCredentialsGroup(ctx, model.ProjectId, model.Region) req = req.CreateCredentialsGroupPayload(objectstorage.CreateCredentialsGroupPayload{ - DisplayName: utils.Ptr(model.CredentialsGroupName), + DisplayName: model.CredentialsGroupName, }) return req } func outputResult(p *print.Printer, outputFormat string, resp *objectstorage.CreateCredentialsGroupResponse) error { - if resp == nil || resp.CredentialsGroup == nil { - return fmt.Errorf("create createndials group response is empty") - } - - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal Object Storage credentials group: %w", err) + return p.OutputResult(outputFormat, resp, func() error { + if resp == nil { + return fmt.Errorf("create credentials group response is empty") } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Object Storage credentials group: %w", err) - } - p.Outputln(string(details)) - - return nil - default: p.Outputf("Created credentials group %q. Credentials group ID: %s\n\n", - utils.PtrString(resp.CredentialsGroup.DisplayName), - utils.PtrString(resp.CredentialsGroup.CredentialsGroupId), + resp.CredentialsGroup.DisplayName, + resp.CredentialsGroup.CredentialsGroupId, ) - p.Outputf("URN: %s\n", utils.PtrString(resp.CredentialsGroup.Urn)) + p.Outputf("URN: %s\n", resp.CredentialsGroup.Urn) return nil - } + }) } diff --git a/internal/cmd/object-storage/credentials-group/create/create_test.go b/internal/cmd/object-storage/credentials-group/create/create_test.go index 60dd53fbd..6068e5fc0 100644 --- a/internal/cmd/object-storage/credentials-group/create/create_test.go +++ b/internal/cmd/object-storage/credentials-group/create/create_test.go @@ -4,32 +4,34 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" -) + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() -var testCredentialsGroupName = "test-name" -var testRegion = "eu01" + +const ( + testCredentialsGroupName = "test-name" + testRegion = "eu01" +) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - credentialsGroupNameFlag: testCredentialsGroupName, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + credentialsGroupNameFlag: testCredentialsGroupName, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -54,7 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { func fixturePayload(mods ...func(payload *objectstorage.CreateCredentialsGroupPayload)) objectstorage.CreateCredentialsGroupPayload { payload := objectstorage.CreateCredentialsGroupPayload{ - DisplayName: utils.Ptr(testCredentialsGroupName), + DisplayName: testCredentialsGroupName, } for _, mod := range mods { mod(&payload) @@ -63,7 +65,7 @@ func fixturePayload(mods ...func(payload *objectstorage.CreateCredentialsGroupPa } func fixtureRequest(mods ...func(request *objectstorage.ApiCreateCredentialsGroupRequest)) objectstorage.ApiCreateCredentialsGroupRequest { - request := testClient.CreateCredentialsGroup(testCtx, testProjectId, testRegion) + request := testClient.DefaultAPI.CreateCredentialsGroup(testCtx, testProjectId, testRegion) request = request.CreateCredentialsGroupPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -74,6 +76,7 @@ func fixtureRequest(mods ...func(request *objectstorage.ApiCreateCredentialsGrou func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -92,21 +95,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -121,46 +124,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -183,7 +147,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { @@ -213,20 +177,20 @@ func TestOutputResult(t *testing.T) { args: args{ createCredentialsGroupResponse: &objectstorage.CreateCredentialsGroupResponse{}, }, - wantErr: true, + wantErr: false, }, { name: "set create credentials group response", args: args{ createCredentialsGroupResponse: &objectstorage.CreateCredentialsGroupResponse{ - CredentialsGroup: &objectstorage.CredentialsGroup{}, + CredentialsGroup: objectstorage.CredentialsGroup{}, }, }, wantErr: false, }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.createCredentialsGroupResponse); (err != nil) != tt.wantErr { diff --git a/internal/cmd/object-storage/credentials-group/credentials_group.go b/internal/cmd/object-storage/credentials-group/credentials_group.go index 0803796f4..e9ce52dbd 100644 --- a/internal/cmd/object-storage/credentials-group/credentials_group.go +++ b/internal/cmd/object-storage/credentials-group/credentials_group.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/credentials-group/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/credentials-group/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials-group", Short: "Provides functionality for Object Storage credentials group", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/object-storage/credentials-group/delete/delete.go b/internal/cmd/object-storage/credentials-group/delete/delete.go index 27dda8460..ac14221e1 100644 --- a/internal/cmd/object-storage/credentials-group/delete/delete.go +++ b/internal/cmd/object-storage/credentials-group/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +16,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) const ( @@ -26,7 +28,7 @@ type inputModel struct { CredentialsGroupId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialsGroupIdArg), Short: "Deletes a credentials group that holds Object Storage access credentials", @@ -39,29 +41,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - credentialsGroupLabel, err := objectStorageUtils.GetCredentialsGroupName(ctx, apiClient, model.ProjectId, model.CredentialsGroupId, model.Region) + credentialsGroupLabel, err := objectStorageUtils.GetCredentialsGroupName(ctx, apiClient.DefaultAPI, model.ProjectId, model.CredentialsGroupId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get credentials group name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials group name: %v", err) credentialsGroupLabel = model.CredentialsGroupId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials group %q? (This cannot be undone)", credentialsGroupLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials group %q? (This cannot be undone)", credentialsGroupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -71,7 +71,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Object Storage credentials group: %w", err) } - p.Info("Deleted credentials group %q\n", credentialsGroupLabel) + params.Printer.Info("Deleted credentials group %q\n", credentialsGroupLabel) return nil }, } @@ -91,19 +91,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsGroupId: credentialsGroupId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiDeleteCredentialsGroupRequest { - req := apiClient.DeleteCredentialsGroup(ctx, model.ProjectId, model.Region, model.CredentialsGroupId) + req := apiClient.DefaultAPI.DeleteCredentialsGroup(ctx, model.ProjectId, model.Region, model.CredentialsGroupId) return req } diff --git a/internal/cmd/object-storage/credentials-group/delete/delete_test.go b/internal/cmd/object-storage/credentials-group/delete/delete_test.go index 1711bd33c..e2c2e33d1 100644 --- a/internal/cmd/object-storage/credentials-group/delete/delete_test.go +++ b/internal/cmd/object-storage/credentials-group/delete/delete_test.go @@ -5,24 +5,22 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() var testCredentialsGroupId = uuid.NewString() -var testRegion = "eu01" + +const testRegion = "eu01" func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -36,8 +34,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -61,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiDeleteCredentialsGroupRequest)) objectstorage.ApiDeleteCredentialsGroupRequest { - request := testClient.DeleteCredentialsGroup(testCtx, testProjectId, testRegion, testCredentialsGroupId) + request := testClient.DefaultAPI.DeleteCredentialsGroup(testCtx, testProjectId, testRegion, testCredentialsGroupId) for _, mod := range mods { mod(&request) } @@ -105,7 +103,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -113,7 +111,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -121,7 +119,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -141,54 +139,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -211,7 +162,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { diff --git a/internal/cmd/object-storage/credentials-group/list/list.go b/internal/cmd/object-storage/credentials-group/list/list.go index 876392113..6c777f0ca 100644 --- a/internal/cmd/object-storage/credentials-group/list/list.go +++ b/internal/cmd/object-storage/credentials-group/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" ) const ( @@ -28,7 +28,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all credentials groups that hold Object Storage access credentials", @@ -45,15 +45,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 credentials groups`, "$ stackit object-storage credentials-group list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -64,17 +64,13 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list Object Storage credentials groups: %w", err) } - credentialsGroups := *resp.CredentialsGroups - if len(credentialsGroups) == 0 { - p.Info("No credentials groups found for your project") - return nil - } + credentialsGroups := resp.GetCredentialsGroups() // Truncate output if model.Limit != nil && len(credentialsGroups) > int(*model.Limit) { credentialsGroups = credentialsGroups[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentialsGroups) + return outputResult(params.Printer, model.OutputFormat, credentialsGroups) }, } configureFlags(cmd) @@ -85,7 +81,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -104,50 +100,30 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiListCredentialsGroupsRequest { - req := apiClient.ListCredentialsGroups(ctx, model.ProjectId, model.Region) + req := apiClient.DefaultAPI.ListCredentialsGroups(ctx, model.ProjectId, model.Region) return req } func outputResult(p *print.Printer, outputFormat string, credentialsGroups []objectstorage.CredentialsGroup) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentialsGroups, "", " ") - if err != nil { - return fmt.Errorf("marshal Object Storage credentials group list: %w", err) + return p.OutputResult(outputFormat, credentialsGroups, func() error { + if len(credentialsGroups) == 0 { + p.Outputf("No credentials groups found for your project") + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentialsGroups, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Object Storage credentials group list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "URN") for i := range credentialsGroups { c := credentialsGroups[i] table.AddRow( - utils.PtrString(c.CredentialsGroupId), - utils.PtrString(c.DisplayName), - utils.PtrString(c.Urn), + c.CredentialsGroupId, + c.DisplayName, + c.Urn, ) } err := table.Display(p) @@ -155,5 +131,5 @@ func outputResult(p *print.Printer, outputFormat string, credentialsGroups []obj return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/object-storage/credentials-group/list/list_test.go b/internal/cmd/object-storage/credentials-group/list/list_test.go index 2a7ee01ff..520a0e011 100644 --- a/internal/cmd/object-storage/credentials-group/list/list_test.go +++ b/internal/cmd/object-storage/credentials-group/list/list_test.go @@ -4,31 +4,32 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() -var testRegion = "eu01" + +const testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", - regionFlag: "eu01", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", + globalflags.RegionFlag: "eu01", } for _, mod := range mods { mod(flagValues) @@ -52,7 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiListCredentialsGroupsRequest)) objectstorage.ApiListCredentialsGroupsRequest { - request := testClient.ListCredentialsGroups(testCtx, testProjectId, testRegion) + request := testClient.DefaultAPI.ListCredentialsGroups(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -62,6 +63,7 @@ func fixtureRequest(mods ...func(request *objectstorage.ApiListCredentialsGroups func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -80,21 +82,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -116,46 +118,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -178,7 +141,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { @@ -219,7 +182,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentialsGroups); (err != nil) != tt.wantErr { diff --git a/internal/cmd/object-storage/credentials/create/create.go b/internal/cmd/object-storage/credentials/create/create.go index 71c854dd3..a9ce8fe47 100644 --- a/internal/cmd/object-storage/credentials/create/create.go +++ b/internal/cmd/object-storage/credentials/create/create.go @@ -2,12 +2,14 @@ package create import ( "context" - "encoding/json" "fmt" "time" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,8 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" objectStorageUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/utils" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" ) const ( @@ -33,7 +33,7 @@ type inputModel struct { HidePassword bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates credentials for an Object Storage credentials group", @@ -47,31 +47,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create credentials for a credentials group with ID "xxx", including a specific expiration date`, "$ stackit object-storage credentials create --credentials-group-id xxx --expire-date 2024-03-06T00:00:00.000Z"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - credentialsGroupLabel, err := objectStorageUtils.GetCredentialsGroupName(ctx, apiClient, model.ProjectId, model.CredentialsGroupId, model.Region) + credentialsGroupLabel, err := objectStorageUtils.GetCredentialsGroupName(ctx, apiClient.DefaultAPI, model.ProjectId, model.CredentialsGroupId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get credentials group name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials group name: %v", err) credentialsGroupLabel = model.CredentialsGroupId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create credentials in group %q?", credentialsGroupLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create credentials in group %q?", credentialsGroupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Object Storage credentials: %w", err) } - return outputResult(p, model.OutputFormat, credentialsGroupLabel, resp) + return outputResult(params.Printer, model.OutputFormat, credentialsGroupLabel, resp) }, } configureFlags(cmd) @@ -96,7 +94,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -116,20 +114,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { CredentialsGroupId: flags.FlagToStringValue(p, cmd, credentialsGroupIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiCreateAccessKeyRequest { - req := apiClient.CreateAccessKey(ctx, model.ProjectId, model.Region) + req := apiClient.DefaultAPI.CreateAccessKey(ctx, model.ProjectId, model.Region) req = req.CredentialsGroup(model.CredentialsGroupId) req = req.CreateAccessKeyPayload(objectstorage.CreateAccessKeyPayload{ Expires: model.ExpireDate, @@ -142,34 +132,17 @@ func outputResult(p *print.Printer, outputFormat, credentialsGroupLabel string, return fmt.Errorf("create access key response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal Object Storage credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Object Storage credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { expireDate := "Never" - if resp.Expires != nil && *resp.Expires != "" { - expireDate = *resp.Expires + if resp.Expires.IsSet() && *resp.Expires.Get() != "" { + expireDate = *resp.Expires.Get() } - p.Outputf("Created credentials in group %q. Credentials ID: %s\n\n", credentialsGroupLabel, utils.PtrString(resp.KeyId)) - p.Outputf("Access Key ID: %s\n", utils.PtrString(resp.AccessKey)) - p.Outputf("Secret Access Key: %s\n", utils.PtrString(resp.SecretAccessKey)) + p.Outputf("Created credentials in group %q. Credentials ID: %s\n\n", credentialsGroupLabel, resp.KeyId) + p.Outputf("Access Key ID: %s\n", resp.AccessKey) + p.Outputf("Secret Access Key: %s\n", resp.SecretAccessKey) p.Outputf("Expire Date: %s\n", expireDate) return nil - } + }) } diff --git a/internal/cmd/object-storage/credentials/create/create_test.go b/internal/cmd/object-storage/credentials/create/create_test.go index 03257beda..c960ddd49 100644 --- a/internal/cmd/object-storage/credentials/create/create_test.go +++ b/internal/cmd/object-storage/credentials/create/create_test.go @@ -5,34 +5,37 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() var testCredentialsGroupId = uuid.NewString() -var testExpirationDate = "2024-01-01T00:00:00Z" -var testRegion = "eu01" + +const ( + testExpirationDate = "2024-01-01T00:00:00Z" + testRegion = "eu01" +) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - credentialsGroupIdFlag: testCredentialsGroupId, - expireDateFlag: testExpirationDate, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + credentialsGroupIdFlag: testCredentialsGroupId, + expireDateFlag: testExpirationDate, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -76,7 +79,7 @@ func fixturePayload(mods ...func(payload *objectstorage.CreateAccessKeyPayload)) } func fixtureRequest(mods ...func(request *objectstorage.ApiCreateAccessKeyRequest)) objectstorage.ApiCreateAccessKeyRequest { - request := testClient.CreateAccessKey(testCtx, testProjectId, testRegion) + request := testClient.DefaultAPI.CreateAccessKey(testCtx, testProjectId, testRegion) request = request.CreateAccessKeyPayload(fixturePayload()) request = request.CredentialsGroup(testCredentialsGroupId) for _, mod := range mods { @@ -88,6 +91,7 @@ func fixtureRequest(mods ...func(request *objectstorage.ApiCreateAccessKeyReques func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -106,21 +110,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -180,46 +184,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -242,7 +207,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { @@ -277,7 +242,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentialsGroupLabel, tt.args.createAccessKeyResponse); (err != nil) != tt.wantErr { diff --git a/internal/cmd/object-storage/credentials/credentials.go b/internal/cmd/object-storage/credentials/credentials.go index e96b86072..4a271019e 100644 --- a/internal/cmd/object-storage/credentials/credentials.go +++ b/internal/cmd/object-storage/credentials/credentials.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/credentials/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/credentials/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", Short: "Provides functionality for Object Storage credentials", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/object-storage/credentials/delete/delete.go b/internal/cmd/object-storage/credentials/delete/delete.go index ed7fedb5e..bc707923a 100644 --- a/internal/cmd/object-storage/credentials/delete/delete.go +++ b/internal/cmd/object-storage/credentials/delete/delete.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" objectStorageUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/utils" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" ) const ( @@ -27,7 +30,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialsIdArg), Short: "Deletes credentials of an Object Storage credentials group", @@ -40,35 +43,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - credentialsGroupLabel, err := objectStorageUtils.GetCredentialsGroupName(ctx, apiClient, model.ProjectId, model.CredentialsGroupId, model.Region) + credentialsGroupLabel, err := objectStorageUtils.GetCredentialsGroupName(ctx, apiClient.DefaultAPI, model.ProjectId, model.CredentialsGroupId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get credentials group name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials group name: %v", err) credentialsGroupLabel = model.CredentialsGroupId } - credentialsLabel, err := objectStorageUtils.GetCredentialsName(ctx, apiClient, model.ProjectId, model.CredentialsGroupId, model.CredentialsId, model.Region) + credentialsLabel, err := objectStorageUtils.GetCredentialsName(ctx, apiClient.DefaultAPI, model.ProjectId, model.CredentialsGroupId, model.CredentialsId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get credentials name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials name: %v", err) credentialsLabel = model.CredentialsId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials %q of credentials group %q? (This cannot be undone)", credentialsLabel, credentialsGroupLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials %q of credentials group %q? (This cannot be undone)", credentialsLabel, credentialsGroupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Object Storage credentials: %w", err) } - p.Info("Deleted credentials %q of credentials group %q\n", credentialsLabel, credentialsGroupLabel) + params.Printer.Info("Deleted credentials %q of credentials group %q\n", credentialsLabel, credentialsGroupLabel) return nil }, } @@ -107,20 +108,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiDeleteAccessKeyRequest { - req := apiClient.DeleteAccessKey(ctx, model.ProjectId, model.Region, model.CredentialsId) + req := apiClient.DefaultAPI.DeleteAccessKey(ctx, model.ProjectId, model.Region, model.CredentialsId) req = req.CredentialsGroup(model.CredentialsGroupId) return req } diff --git a/internal/cmd/object-storage/credentials/delete/delete_test.go b/internal/cmd/object-storage/credentials/delete/delete_test.go index 09ee7d04b..12be8797a 100644 --- a/internal/cmd/object-storage/credentials/delete/delete_test.go +++ b/internal/cmd/object-storage/credentials/delete/delete_test.go @@ -5,25 +5,25 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() var testCredentialsGroupId = uuid.NewString() -var testCredentialsId = "keyID" -var testRegion = "eu01" + +const ( + testCredentialsId = "keyID" + testRegion = "eu01" +) func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -37,9 +37,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - credentialsGroupIdFlag: testCredentialsGroupId, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + credentialsGroupIdFlag: testCredentialsGroupId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -64,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiDeleteAccessKeyRequest)) objectstorage.ApiDeleteAccessKeyRequest { - request := testClient.DeleteAccessKey(testCtx, testProjectId, testRegion, testCredentialsId) + request := testClient.DefaultAPI.DeleteAccessKey(testCtx, testProjectId, testRegion, testCredentialsId) request = request.CredentialsGroup(testCredentialsGroupId) for _, mod := range mods { mod(&request) @@ -109,7 +109,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -117,7 +117,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -125,7 +125,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -160,54 +160,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -230,7 +183,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { diff --git a/internal/cmd/object-storage/credentials/list/list.go b/internal/cmd/object-storage/credentials/list/list.go index c1a6d63dd..119de7c76 100644 --- a/internal/cmd/object-storage/credentials/list/list.go +++ b/internal/cmd/object-storage/credentials/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,8 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" objectStorageUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" ) const ( @@ -31,7 +31,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all credentials for an Object Storage credentials group", @@ -48,15 +48,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 credentials for a credentials group with ID "xxx"`, "$ stackit object-storage credentials list --credentials-group-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,23 +67,19 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list Object Storage credentials: %w", err) } - credentials := *resp.AccessKeys - if len(credentials) == 0 { - credentialsGroupLabel, err := objectStorageUtils.GetCredentialsGroupName(ctx, apiClient, model.ProjectId, model.CredentialsGroupId, model.Region) - if err != nil { - p.Debug(print.ErrorLevel, "get credentials group name: %v", err) - credentialsGroupLabel = model.CredentialsGroupId - } - - p.Info("No credentials found for credentials group %q\n", credentialsGroupLabel) - return nil + credentials := resp.GetAccessKeys() + + credentialsGroupLabel, err := objectStorageUtils.GetCredentialsGroupName(ctx, apiClient.DefaultAPI, model.ProjectId, model.CredentialsGroupId, model.Region) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get credentials group name: %v", err) + credentialsGroupLabel = model.CredentialsGroupId } // Truncate output if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentials) + return outputResult(params.Printer, model.OutputFormat, credentialsGroupLabel, credentials) }, } configureFlags(cmd) @@ -98,7 +94,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -118,55 +114,38 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiListAccessKeysRequest { - req := apiClient.ListAccessKeys(ctx, model.ProjectId, model.Region) + req := apiClient.DefaultAPI.ListAccessKeys(ctx, model.ProjectId, model.Region) req = req.CredentialsGroup(model.CredentialsGroupId) return req } -func outputResult(p *print.Printer, outputFormat string, credentials []objectstorage.AccessKey) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal Object Storage credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Object Storage credentials list: %w", err) +func outputResult(p *print.Printer, outputFormat, credentialsGroupLabel string, credentials []objectstorage.AccessKey) error { + return p.OutputResult(outputFormat, credentials, func() error { + if len(credentials) == 0 { + p.Outputf("No credentials found for credentials group %q\n", credentialsGroupLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("CREDENTIALS ID", "ACCESS KEY ID", "EXPIRES AT") for i := range credentials { c := credentials[i] - expiresAt := utils.PtrStringDefault(c.Expires, "Never") - table.AddRow(utils.PtrString(c.KeyId), utils.PtrString(c.DisplayName), expiresAt) + expiresAt := "Never" + if c.Expires != "" { + expiresAt = c.Expires + } + table.AddRow(c.KeyId, c.DisplayName, expiresAt) } err := table.Display(p) if err != nil { return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/object-storage/credentials/list/list_test.go b/internal/cmd/object-storage/credentials/list/list_test.go index a50921b69..b950ac36a 100644 --- a/internal/cmd/object-storage/credentials/list/list_test.go +++ b/internal/cmd/object-storage/credentials/list/list_test.go @@ -4,33 +4,33 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() var testCredentialsGroupId = uuid.NewString() var testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - credentialsGroupIdFlag: testCredentialsGroupId, - limitFlag: "10", - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + credentialsGroupIdFlag: testCredentialsGroupId, + limitFlag: "10", + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -55,7 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiListAccessKeysRequest)) objectstorage.ApiListAccessKeysRequest { - request := testClient.ListAccessKeys(testCtx, testProjectId, testRegion) + request := testClient.DefaultAPI.ListAccessKeys(testCtx, testProjectId, testRegion) request = request.CredentialsGroup(testCredentialsGroupId) for _, mod := range mods { mod(&request) @@ -66,6 +66,7 @@ func fixtureRequest(mods ...func(request *objectstorage.ApiListAccessKeysRequest func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -84,21 +85,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -141,46 +142,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -203,7 +165,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { @@ -215,8 +177,9 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { - outputFormat string - credentials []objectstorage.AccessKey + outputFormat string + credentialsGroupLabel string + credentials []objectstorage.AccessKey } tests := []struct { name string @@ -244,10 +207,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.credentialsGroupLabel, tt.args.credentials); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/object-storage/disable/disable.go b/internal/cmd/object-storage/disable/disable.go index 56e6f940d..22eba8a93 100644 --- a/internal/cmd/object-storage/disable/disable.go +++ b/internal/cmd/object-storage/disable/disable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,14 +15,14 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "disable", Short: "Disables Object Storage for a project", @@ -31,31 +33,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Disable Object Storage functionality for your project.`, "$ stackit object-storage disable"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to disable Object Storage for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to disable Object Storage for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -69,14 +69,14 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Async { operationState = "Triggered disablement of" } - p.Info("%s Object Storage for project %q\n", operationState, projectLabel) + params.Printer.Info("%s Object Storage for project %q\n", operationState, projectLabel) return nil }, } return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -86,19 +86,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiDisableServiceRequest { - req := apiClient.DisableService(ctx, model.ProjectId, model.Region) + req := apiClient.DefaultAPI.DisableService(ctx, model.ProjectId, model.Region) return req } diff --git a/internal/cmd/object-storage/disable/disable_test.go b/internal/cmd/object-storage/disable/disable_test.go index cb65e8961..9f6ce3322 100644 --- a/internal/cmd/object-storage/disable/disable_test.go +++ b/internal/cmd/object-storage/disable/disable_test.go @@ -5,29 +5,26 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() -var testRegion = "eu01" + +const testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -50,7 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiDisableServiceRequest)) objectstorage.ApiDisableServiceRequest { - request := testClient.DisableService(testCtx, testProjectId, testRegion) + request := testClient.DefaultAPI.DisableService(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -60,6 +57,7 @@ func fixtureRequest(mods ...func(request *objectstorage.ApiDisableServiceRequest func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -78,21 +76,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -100,46 +98,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -162,7 +121,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { diff --git a/internal/cmd/object-storage/enable/enable.go b/internal/cmd/object-storage/enable/enable.go index cec0d6e7d..0018d56d9 100644 --- a/internal/cmd/object-storage/enable/enable.go +++ b/internal/cmd/object-storage/enable/enable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,14 +15,14 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/client" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "enable", Short: "Enables Object Storage for a project", @@ -31,31 +33,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Enable Object Storage functionality for your project`, "$ stackit object-storage enable"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to enable Object Storage for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to enable Object Storage for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -69,14 +69,14 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Async { operationState = "Triggered enablement of" } - p.Info("%s Object Storage for project %q\n", operationState, projectLabel) + params.Printer.Info("%s Object Storage for project %q\n", operationState, projectLabel) return nil }, } return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -86,19 +86,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstorage.APIClient) objectstorage.ApiEnableServiceRequest { - req := apiClient.EnableService(ctx, model.ProjectId, model.Region) + req := apiClient.DefaultAPI.EnableService(ctx, model.ProjectId, model.Region) return req } diff --git a/internal/cmd/object-storage/enable/enable_test.go b/internal/cmd/object-storage/enable/enable_test.go index 562bb6907..936b2d0f9 100644 --- a/internal/cmd/object-storage/enable/enable_test.go +++ b/internal/cmd/object-storage/enable/enable_test.go @@ -5,29 +5,26 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -var projectIdFlag = globalflags.ProjectIdFlag -var regionFlag = globalflags.RegionFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &objectstorage.APIClient{} +var testClient = &objectstorage.APIClient{DefaultAPI: &objectstorage.DefaultAPIService{}} var testProjectId = uuid.NewString() -var testRegion = "eu01" + +const testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - regionFlag: testRegion, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -50,7 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *objectstorage.ApiEnableServiceRequest)) objectstorage.ApiEnableServiceRequest { - request := testClient.EnableService(testCtx, testProjectId, testRegion) + request := testClient.DefaultAPI.EnableService(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -60,6 +57,7 @@ func fixtureRequest(mods ...func(request *objectstorage.ApiEnableServiceRequest) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -78,21 +76,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -100,46 +98,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -162,7 +121,7 @@ func TestBuildRequest(t *testing.T) { request := buildRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, - cmp.AllowUnexported(tt.expectedRequest), + cmp.AllowUnexported(tt.expectedRequest, objectstorage.DefaultAPIService{}), cmpopts.EquateComparable(testCtx), ) if diff != "" { diff --git a/internal/cmd/object-storage/object_storage.go b/internal/cmd/object-storage/object_storage.go index 0ba397592..1f4c11799 100644 --- a/internal/cmd/object-storage/object_storage.go +++ b/internal/cmd/object-storage/object_storage.go @@ -2,18 +2,19 @@ package objectstorage import ( "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/bucket" + complianceLock "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/compliance-lock" "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/credentials" credentialsGroup "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/credentials-group" "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/disable" "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage/enable" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "object-storage", Short: "Provides functionality for Object Storage", @@ -21,14 +22,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(bucket.NewCmd(p)) - cmd.AddCommand(disable.NewCmd(p)) - cmd.AddCommand(enable.NewCmd(p)) - cmd.AddCommand(credentialsGroup.NewCmd(p)) - cmd.AddCommand(credentials.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(bucket.NewCmd(params)) + cmd.AddCommand(disable.NewCmd(params)) + cmd.AddCommand(enable.NewCmd(params)) + cmd.AddCommand(credentialsGroup.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) + cmd.AddCommand(complianceLock.NewCmd(params)) } diff --git a/internal/cmd/observability/credentials/create/create.go b/internal/cmd/observability/credentials/create/create.go index 9852ee352..6c2f8289f 100644 --- a/internal/cmd/observability/credentials/create/create.go +++ b/internal/cmd/observability/credentials/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/observability" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client" observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/observability" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates credentials for an Observability instance.", @@ -42,31 +43,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create credentials for Observability instance with ID "xxx"`, "$ stackit observability credentials create --instance-id xxx"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -79,7 +78,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create credentials for Observability instance: %w", err) } - return outputResult(p, model.OutputFormat, instanceLabel, resp) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, resp) }, } configureFlags(cmd) @@ -93,7 +92,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -115,24 +114,7 @@ func outputResult(p *print.Printer, outputFormat, instanceLabel string, resp *ob return fmt.Errorf("response is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal Observability credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Observability credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created credentials for instance %q.\n\n", instanceLabel) if resp.Credentials != nil { @@ -145,5 +127,5 @@ func outputResult(p *print.Printer, outputFormat, instanceLabel string, resp *ob p.Outputf("Password: %s\n", utils.PtrString(resp.Credentials.Password)) } return nil - } + }) } diff --git a/internal/cmd/observability/credentials/create/create_test.go b/internal/cmd/observability/credentials/create/create_test.go index c261948c7..0bd9c6ea6 100644 --- a/internal/cmd/observability/credentials/create/create_test.go +++ b/internal/cmd/observability/credentials/create/create_test.go @@ -4,9 +4,13 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/observability" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/observability" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -58,6 +62,7 @@ func fixtureRequest(mods ...func(request *observability.ApiCreateCredentialsRequ func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -119,45 +124,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) - 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(nil, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -224,7 +191,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/credentials/credentials.go b/internal/cmd/observability/credentials/credentials.go index fb84cf9d6..2c40cc3d2 100644 --- a/internal/cmd/observability/credentials/credentials.go +++ b/internal/cmd/observability/credentials/credentials.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/observability/credentials/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/observability/credentials/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", Short: "Provides functionality for Observability credentials", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/observability/credentials/delete/delete.go b/internal/cmd/observability/credentials/delete/delete.go index a501b949a..a28888dc9 100644 --- a/internal/cmd/observability/credentials/delete/delete.go +++ b/internal/cmd/observability/credentials/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +31,7 @@ type inputModel struct { Username string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", usernameArg), Short: "Deletes credentials of an Observability instance", @@ -42,29 +44,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials for username %q of instance %q? (This cannot be undone)", model.Username, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials for username %q of instance %q? (This cannot be undone)", model.Username, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -74,7 +74,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Observability credentials: %w", err) } - p.Info("Deleted credentials for username %q of instance %q\n", model.Username, instanceLabel) + params.Printer.Info("Deleted credentials for username %q of instance %q\n", model.Username, instanceLabel) return nil }, } diff --git a/internal/cmd/observability/credentials/list/list.go b/internal/cmd/observability/credentials/list/list.go index 9c9452832..f26af68de 100644 --- a/internal/cmd/observability/credentials/list/list.go +++ b/internal/cmd/observability/credentials/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists the usernames of all credentials for an Observability instance", @@ -49,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List the usernames of up to 10 credentials for an Observability instance`, "$ stackit observability credentials list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -72,10 +72,10 @@ func NewCmd(p *print.Printer) *cobra.Command { if len(credentials) == 0 { instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - p.Info("No credentials found for instance %q\n", instanceLabel) + params.Printer.Info("No credentials found for instance %q\n", instanceLabel) return nil } @@ -83,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentials) + return outputResult(params.Printer, model.OutputFormat, credentials) }, } configureFlags(cmd) @@ -98,7 +98,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -125,24 +125,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *observabili } func outputResult(p *print.Printer, outputFormat string, credentials []observability.ServiceKeysList) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal Observability credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Observability credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, credentials, func() error { table := tables.NewTable() table.SetHeader("USERNAME") for i := range credentials { @@ -155,5 +138,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials []observabi } return nil - } + }) } diff --git a/internal/cmd/observability/credentials/list/list_test.go b/internal/cmd/observability/credentials/list/list_test.go index fa23d5dfe..f2ed00e46 100644 --- a/internal/cmd/observability/credentials/list/list_test.go +++ b/internal/cmd/observability/credentials/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -61,6 +64,7 @@ func fixtureRequest(mods ...func(request *observability.ApiListCredentialsReques func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -136,45 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) - 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(nil, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -238,7 +204,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/grafana/describe/describe.go b/internal/cmd/observability/grafana/describe/describe.go index 9a364499c..88b7f70b6 100644 --- a/internal/cmd/observability/grafana/describe/describe.go +++ b/internal/cmd/observability/grafana/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -21,7 +21,8 @@ import ( ) const ( - instanceIdArg = "INSTANCE_ID" + instanceIdArg = "INSTANCE_ID" + // Deprecated: showPasswordFlag is deprecated and will be removed on 2026-07-05. showPasswordFlag = "show-password" ) @@ -31,36 +32,32 @@ type inputModel struct { ShowPassword bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of the Grafana configuration of an Observability instance", - Long: fmt.Sprintf("%s\n%s\n%s", + Long: fmt.Sprintf("%s\n%s", "Shows details of the Grafana configuration of an Observability instance.", `The Grafana dashboard URL and initial credentials (admin user and password) will be shown in the "pretty" output format. These credentials are only valid for first login. Please change the password after first login. After changing, the initial password is no longer valid.`, - `The initial password is hidden by default, if you want to show it use the "--show-password" flag.`, ), Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), Example: examples.Build( examples.NewExample( `Get details of the Grafana configuration of an Observability instance with ID "xxx"`, "$ stackit observability grafana describe xxx"), - examples.NewExample( - `Get details of the Grafana configuration of an Observability instance with ID "xxx" and show the initial admin password`, - "$ stackit observability grafana describe xxx --show-password"), examples.NewExample( `Get details of the Grafana configuration of an Observability instance with ID "xxx" in JSON format`, "$ stackit observability grafana describe xxx --output-format json"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -77,7 +74,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get instance: %w", err) } - return outputResult(p, model.OutputFormat, model.ShowPassword, grafanaConfigsResp, instanceResp) + return outputResult(params.Printer, model.OutputFormat, model.ShowPassword, grafanaConfigsResp, instanceResp) }, } configureFlags(cmd) @@ -86,6 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().BoolP(showPasswordFlag, "s", false, "Show password in output") + cobra.CheckErr(cmd.Flags().MarkDeprecated(showPasswordFlag, "This flag is deprecated and will be removed on 2026-07-05.")) } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -102,15 +100,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ShowPassword: flags.FlagToBoolValue(p, cmd, showPasswordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -130,25 +120,10 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, graf } else if grafanaConfigs == nil { return fmt.Errorf("grafanaConfigs is nil") } + p.Warn("GrafanaAdminPassword and GrafanaAdminUser are deprecated and will be removed on 2026-07-05.") - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(grafanaConfigs, "", " ") - if err != nil { - return fmt.Errorf("marshal Grafana configs: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(grafanaConfigs, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Grafana configs: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, grafanaConfigs, func() error { + //nolint:staticcheck // field is deprecated but still supported until 2026-07-05 initialAdminPassword := utils.PtrString(instance.Instance.GrafanaAdminPassword) if !showPassword { initialAdminPassword = "" @@ -161,6 +136,7 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, graf table.AddSeparator() table.AddRow("SINGLE SIGN-ON", utils.PtrString(grafanaConfigs.UseStackitSso)) table.AddSeparator() + //nolint:staticcheck // field is deprecated but still supported until 2026-07-05 table.AddRow("INITIAL ADMIN USER (DEFAULT)", utils.PtrString(instance.Instance.GrafanaAdminUser)) table.AddSeparator() table.AddRow("INITIAL ADMIN PASSWORD (DEFAULT)", initialAdminPassword) @@ -170,5 +146,5 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, graf } return nil - } + }) } diff --git a/internal/cmd/observability/grafana/describe/describe_test.go b/internal/cmd/observability/grafana/describe/describe_test.go index f282c2964..c1aeb443d 100644 --- a/internal/cmd/observability/grafana/describe/describe_test.go +++ b/internal/cmd/observability/grafana/describe/describe_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -169,7 +171,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -322,7 +324,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.showPassword, tt.args.grafanaConfig, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/grafana/grafana.go b/internal/cmd/observability/grafana/grafana.go index e9ad230d1..7ba2a996f 100644 --- a/internal/cmd/observability/grafana/grafana.go +++ b/internal/cmd/observability/grafana/grafana.go @@ -5,13 +5,13 @@ import ( publicreadaccess "github.com/stackitcloud/stackit-cli/internal/cmd/observability/grafana/public-read-access" singlesignon "github.com/stackitcloud/stackit-cli/internal/cmd/observability/grafana/single-sign-on" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "grafana", Short: "Provides functionality for the Grafana configuration of Observability instances", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(publicreadaccess.NewCmd(p)) - cmd.AddCommand(singlesignon.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(publicreadaccess.NewCmd(params)) + cmd.AddCommand(singlesignon.NewCmd(params)) } diff --git a/internal/cmd/observability/grafana/public-read-access/disable/disable.go b/internal/cmd/observability/grafana/public-read-access/disable/disable.go index ac6cd80a4..0b6b3fcc5 100644 --- a/internal/cmd/observability/grafana/public-read-access/disable/disable.go +++ b/internal/cmd/observability/grafana/public-read-access/disable/disable.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/observability" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -12,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client" observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/observability" "github.com/spf13/cobra" ) @@ -26,7 +29,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("disable %s", instanceIdArg), Short: "Disables public read access for Grafana on Observability instances", @@ -42,13 +45,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -58,12 +61,10 @@ func NewCmd(p *print.Printer) *cobra.Command { instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to disable Grafana public read access for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to disable Grafana public read access for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -76,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("disable grafana public read access: %w", err) } - p.Info("Disabled Grafana public read access for instance %q\n", instanceLabel) + params.Printer.Info("Disabled Grafana public read access for instance %q\n", instanceLabel) return nil }, } @@ -96,15 +97,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/observability/grafana/public-read-access/disable/disable_test.go b/internal/cmd/observability/grafana/public-read-access/disable/disable_test.go index 7e08ac647..89cbf05e9 100644 --- a/internal/cmd/observability/grafana/public-read-access/disable/disable_test.go +++ b/internal/cmd/observability/grafana/public-read-access/disable/disable_test.go @@ -6,14 +6,15 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" "github.com/stackitcloud/stackit-sdk-go/services/observability" + + observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -185,54 +186,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -261,7 +215,7 @@ func TestBuildRequest(t *testing.T) { }), isValid: true, expectedRequest: fixtureRequest(func(request *observability.ApiUpdateGrafanaConfigsRequest) { - *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) { + *request = (*request).UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) { payload.GenericOauth = nil })) }), diff --git a/internal/cmd/observability/grafana/public-read-access/enable/enable.go b/internal/cmd/observability/grafana/public-read-access/enable/enable.go index 9296a1f24..88f58227d 100644 --- a/internal/cmd/observability/grafana/public-read-access/enable/enable.go +++ b/internal/cmd/observability/grafana/public-read-access/enable/enable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +15,9 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" - observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" "github.com/stackitcloud/stackit-sdk-go/services/observability" + + observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" ) const ( @@ -26,7 +29,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("enable %s", instanceIdArg), Short: "Enables public read access for Grafana on Observability instances", @@ -42,13 +45,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -58,12 +61,10 @@ func NewCmd(p *print.Printer) *cobra.Command { instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to enable Grafana public read access for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to enable Grafana public read access for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -76,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("enable grafana public read access: %w", err) } - p.Info("Enabled Grafana public read access for instance %q\n", instanceLabel) + params.Printer.Info("Enabled Grafana public read access for instance %q\n", instanceLabel) return nil }, } @@ -96,15 +97,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/observability/grafana/public-read-access/enable/enable_test.go b/internal/cmd/observability/grafana/public-read-access/enable/enable_test.go index 57ebc5bc3..e81ed3bc4 100644 --- a/internal/cmd/observability/grafana/public-read-access/enable/enable_test.go +++ b/internal/cmd/observability/grafana/public-read-access/enable/enable_test.go @@ -6,14 +6,15 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" "github.com/stackitcloud/stackit-sdk-go/services/observability" + + observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -185,54 +186,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -261,7 +215,7 @@ func TestBuildRequest(t *testing.T) { }), isValid: true, expectedRequest: fixtureRequest(func(request *observability.ApiUpdateGrafanaConfigsRequest) { - *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) { + *request = (*request).UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) { payload.GenericOauth = nil })) }), diff --git a/internal/cmd/observability/grafana/public-read-access/public_read_access.go b/internal/cmd/observability/grafana/public-read-access/public_read_access.go index 8844e1279..bf45ec5df 100644 --- a/internal/cmd/observability/grafana/public-read-access/public_read_access.go +++ b/internal/cmd/observability/grafana/public-read-access/public_read_access.go @@ -3,16 +3,17 @@ package publicreadaccess import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/cmd/observability/grafana/public-read-access/disable" "github.com/stackitcloud/stackit-cli/internal/cmd/observability/grafana/public-read-access/enable" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "public-read-access", Short: "Enable or disable public read access for Grafana in Observability instances", @@ -23,11 +24,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(enable.NewCmd(p)) - cmd.AddCommand(disable.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(enable.NewCmd(params)) + cmd.AddCommand(disable.NewCmd(params)) } diff --git a/internal/cmd/observability/grafana/single-sign-on/disable/disable.go b/internal/cmd/observability/grafana/single-sign-on/disable/disable.go index 018d7827d..a2023c789 100644 --- a/internal/cmd/observability/grafana/single-sign-on/disable/disable.go +++ b/internal/cmd/observability/grafana/single-sign-on/disable/disable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +15,9 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" - observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" "github.com/stackitcloud/stackit-sdk-go/services/observability" + + observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" ) const ( @@ -26,7 +29,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("disable %s", instanceIdArg), Short: "Disables single sign-on for Grafana on Observability instances", @@ -42,13 +45,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -58,12 +61,10 @@ func NewCmd(p *print.Printer) *cobra.Command { instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to disable single sign-on for Grafana for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to disable single sign-on for Grafana for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -76,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("disable single sign-on for grafana: %w", err) } - p.Info("Disabled single sign-on for Grafana for instance %q\n", instanceLabel) + params.Printer.Info("Disabled single sign-on for Grafana for instance %q\n", instanceLabel) return nil }, } @@ -96,15 +97,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/observability/grafana/single-sign-on/disable/disable_test.go b/internal/cmd/observability/grafana/single-sign-on/disable/disable_test.go index 79653608e..704b7a199 100644 --- a/internal/cmd/observability/grafana/single-sign-on/disable/disable_test.go +++ b/internal/cmd/observability/grafana/single-sign-on/disable/disable_test.go @@ -6,14 +6,15 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" "github.com/stackitcloud/stackit-sdk-go/services/observability" + + observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -185,54 +186,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -261,7 +215,7 @@ func TestBuildRequest(t *testing.T) { }), isValid: true, expectedRequest: fixtureRequest(func(request *observability.ApiUpdateGrafanaConfigsRequest) { - *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) { + *request = (*request).UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) { payload.GenericOauth = nil })) }), diff --git a/internal/cmd/observability/grafana/single-sign-on/enable/enable.go b/internal/cmd/observability/grafana/single-sign-on/enable/enable.go index 9a002b9f9..3aca5bbe8 100644 --- a/internal/cmd/observability/grafana/single-sign-on/enable/enable.go +++ b/internal/cmd/observability/grafana/single-sign-on/enable/enable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -26,7 +28,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("enable %s", instanceIdArg), Short: "Enables single sign-on for Grafana on Observability instances", @@ -42,13 +44,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -58,12 +60,10 @@ func NewCmd(p *print.Printer) *cobra.Command { instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to enable single sign-on for Grafana for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to enable single sign-on for Grafana for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -76,7 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("enable single sign-on for grafana: %w", err) } - p.Info("Enabled single sign-on for Grafana for instance %q\n", instanceLabel) + params.Printer.Info("Enabled single sign-on for Grafana for instance %q\n", instanceLabel) return nil }, } @@ -96,15 +96,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/observability/grafana/single-sign-on/enable/enable_test.go b/internal/cmd/observability/grafana/single-sign-on/enable/enable_test.go index e59f23828..f13a2db25 100644 --- a/internal/cmd/observability/grafana/single-sign-on/enable/enable_test.go +++ b/internal/cmd/observability/grafana/single-sign-on/enable/enable_test.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -185,54 +185,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -261,7 +214,7 @@ func TestBuildRequest(t *testing.T) { }), isValid: true, expectedRequest: fixtureRequest(func(request *observability.ApiUpdateGrafanaConfigsRequest) { - *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) { + *request = (*request).UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) { payload.GenericOauth = nil })) }), diff --git a/internal/cmd/observability/grafana/single-sign-on/single_sign_on.go b/internal/cmd/observability/grafana/single-sign-on/single_sign_on.go index c53c4a5cf..293066b8f 100644 --- a/internal/cmd/observability/grafana/single-sign-on/single_sign_on.go +++ b/internal/cmd/observability/grafana/single-sign-on/single_sign_on.go @@ -3,16 +3,17 @@ package singlesignon import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/cmd/observability/grafana/single-sign-on/disable" "github.com/stackitcloud/stackit-cli/internal/cmd/observability/grafana/single-sign-on/enable" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "single-sign-on", Aliases: []string{"sso"}, @@ -24,11 +25,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(enable.NewCmd(p)) - cmd.AddCommand(disable.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(enable.NewCmd(params)) + cmd.AddCommand(disable.NewCmd(params)) } diff --git a/internal/cmd/observability/instance/create/create.go b/internal/cmd/observability/instance/create/create.go index 57e9d888f..72d53ebfe 100644 --- a/internal/cmd/observability/instance/create/create.go +++ b/internal/cmd/observability/instance/create/create.go @@ -2,11 +2,11 @@ package create import ( "context" - "encoding/json" "errors" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -19,9 +19,10 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" - observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" "github.com/stackitcloud/stackit-sdk-go/services/observability" "github.com/stackitcloud/stackit-sdk-go/services/observability/wait" + + observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils" ) const ( @@ -38,7 +39,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates an Observability instance", @@ -52,31 +53,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create an Observability instance with name "my-instance" and specify plan by ID`, "$ stackit observability instance create --name my-instance --plan-id xxx"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create an Observability instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create an Observability instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -96,16 +95,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, instanceId, model.ProjectId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, instanceId, model.ProjectId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for Observability instance creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) }, } configureFlags(cmd) @@ -121,7 +120,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -148,15 +147,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { PlanName: planName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -205,29 +196,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, projectLabe return fmt.Errorf("resp is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal Observability instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Observability instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, utils.PtrString(resp.InstanceId)) return nil - } + }) } diff --git a/internal/cmd/observability/instance/create/create_test.go b/internal/cmd/observability/instance/create/create_test.go index f36d5ae8a..5aac98b82 100644 --- a/internal/cmd/observability/instance/create/create_test.go +++ b/internal/cmd/observability/instance/create/create_test.go @@ -5,8 +5,11 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -98,6 +101,7 @@ func fixturePlansResponse(mods ...func(response *observability.PlansResponse)) * func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -174,46 +178,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -361,7 +326,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/instance/delete/delete.go b/internal/cmd/observability/instance/delete/delete.go index abe93e2a0..d9fc972ab 100644 --- a/internal/cmd/observability/instance/delete/delete.go +++ b/internal/cmd/observability/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes an Observability instance", @@ -41,29 +43,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -75,20 +75,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.InstanceId, model.ProjectId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.InstanceId, model.ProjectId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for Observability instance deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -108,15 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/observability/instance/delete/delete_test.go b/internal/cmd/observability/instance/delete/delete_test.go index 8375214bb..5d6bce774 100644 --- a/internal/cmd/observability/instance/delete/delete_test.go +++ b/internal/cmd/observability/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,54 +137,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/observability/instance/describe/describe.go b/internal/cmd/observability/instance/describe/describe.go index 157bd28a4..e9e4a256a 100644 --- a/internal/cmd/observability/instance/describe/describe.go +++ b/internal/cmd/observability/instance/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +28,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of an Observability instance", @@ -44,12 +44,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -61,7 +61,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read Observability instance: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -80,15 +80,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -102,24 +94,7 @@ func outputResult(p *print.Printer, outputFormat string, instance *observability return fmt.Errorf("instance is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal Observability instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Observability instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(instance.Id)) table.AddSeparator() @@ -151,5 +126,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *observability } return nil - } + }) } diff --git a/internal/cmd/observability/instance/describe/describe_test.go b/internal/cmd/observability/instance/describe/describe_test.go index c55020fa7..dd1c07d42 100644 --- a/internal/cmd/observability/instance/describe/describe_test.go +++ b/internal/cmd/observability/instance/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,54 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -241,7 +197,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/instance/instance.go b/internal/cmd/observability/instance/instance.go index efd7d4974..955ae39ec 100644 --- a/internal/cmd/observability/instance/instance.go +++ b/internal/cmd/observability/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/observability/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/observability/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for Observability instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) } diff --git a/internal/cmd/observability/instance/list/list.go b/internal/cmd/observability/instance/list/list.go index b540bee32..8712e2fcd 100644 --- a/internal/cmd/observability/instance/list/list.go +++ b/internal/cmd/observability/instance/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/observability" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/observability" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all Observability instances", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 Observability instances`, "$ stackit observability instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,12 +68,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } instances := *resp.Instances if len(instances) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No instances found for project %q\n", projectLabel) + params.Printer.Info("No instances found for project %q\n", projectLabel) return nil } @@ -81,7 +82,7 @@ func NewCmd(p *print.Printer) *cobra.Command { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, instances) }, } @@ -93,7 +94,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +113,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -130,24 +123,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *observabili } func outputResult(p *print.Printer, outputFormat string, instances []observability.ProjectInstanceFull) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal Observability instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Observability instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instances, func() error { table := tables.NewTable() table.SetHeader("ID", "NAME", "PLAN", "STATUS") for i := range instances { @@ -165,5 +141,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []observabili } return nil - } + }) } diff --git a/internal/cmd/observability/instance/list/list_test.go b/internal/cmd/observability/instance/list/list_test.go index e8a87f48a..456dbfb20 100644 --- a/internal/cmd/observability/instance/list/list_test.go +++ b/internal/cmd/observability/instance/list/list_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/observability" ) @@ -59,6 +61,7 @@ func fixtureRequest(mods ...func(request *observability.ApiListInstancesRequest) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -113,47 +116,7 @@ func TestParseInput(t *testing.T) { 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) - } - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -217,7 +180,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/instance/update/update.go b/internal/cmd/observability/instance/update/update.go index 192c84366..38b2fe693 100644 --- a/internal/cmd/observability/instance/update/update.go +++ b/internal/cmd/observability/instance/update/update.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -38,7 +40,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates an Observability instance", @@ -57,29 +59,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) if err != nil || instanceLabel == "" { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -100,20 +100,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.UpdateInstanceWaitHandler(ctx, apiClient, instanceId, model.ProjectId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.UpdateInstanceWaitHandler(ctx, apiClient, instanceId, model.ProjectId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for Observability instance update: %w", err) } - s.Stop() } operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -157,15 +157,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceName: instanceName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/observability/instance/update/update_test.go b/internal/cmd/observability/instance/update/update_test.go index 0b5177b27..6a576ce5d 100644 --- a/internal/cmd/observability/instance/update/update_test.go +++ b/internal/cmd/observability/instance/update/update_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -239,54 +239,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/observability/observability.go b/internal/cmd/observability/observability.go index 8737a716e..66345691a 100644 --- a/internal/cmd/observability/observability.go +++ b/internal/cmd/observability/observability.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/observability/plans" scrapeconfig "github.com/stackitcloud/stackit-cli/internal/cmd/observability/scrape-config" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "observability", Short: "Provides functionality for Observability", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(grafana.NewCmd(p)) - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(credentials.NewCmd(p)) - cmd.AddCommand(scrapeconfig.NewCmd(p)) - cmd.AddCommand(plans.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(grafana.NewCmd(params)) + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) + cmd.AddCommand(scrapeconfig.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) } diff --git a/internal/cmd/observability/plans/plans.go b/internal/cmd/observability/plans/plans.go index d42cfb8a0..5ef3952ed 100644 --- a/internal/cmd/observability/plans/plans.go +++ b/internal/cmd/observability/plans/plans.go @@ -2,9 +2,10 @@ package plans import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/observability" ) @@ -30,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "plans", Short: "Lists all Observability service plans", @@ -47,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 Observability service plans`, "$ stackit observability plans --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,12 +68,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } plans := *resp.Plans if len(plans) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No plans found for project %q\n", projectLabel) + params.Printer.Info("No plans found for project %q\n", projectLabel) return nil } @@ -82,7 +82,7 @@ func NewCmd(p *print.Printer) *cobra.Command { plans = plans[:*model.Limit] } - return outputResult(p, model.OutputFormat, plans) + return outputResult(params.Printer, model.OutputFormat, plans) }, } @@ -94,7 +94,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -113,15 +113,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -131,24 +123,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *observabili } func outputResult(p *print.Printer, outputFormat string, plans []observability.Plan) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(plans, "", " ") - if err != nil { - return fmt.Errorf("marshal Observability plans: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(plans, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Observability plans: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, plans, func() error { table := tables.NewTable() table.SetHeader("ID", "PLAN NAME", "DESCRIPTION") for i := range plans { @@ -167,5 +142,5 @@ func outputResult(p *print.Printer, outputFormat string, plans []observability.P } return nil - } + }) } diff --git a/internal/cmd/observability/plans/plans_test.go b/internal/cmd/observability/plans/plans_test.go index 047c3c5f6..65fb129d3 100644 --- a/internal/cmd/observability/plans/plans_test.go +++ b/internal/cmd/observability/plans/plans_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/observability" ) @@ -59,6 +61,7 @@ func fixtureRequest(mods ...func(request *observability.ApiListPlansRequest)) ob func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -113,48 +116,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -218,7 +180,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.plans); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/scrape-config/create/create.go b/internal/cmd/observability/scrape-config/create/create.go index 0856e6b19..87d206cfe 100644 --- a/internal/cmd/observability/scrape-config/create/create.go +++ b/internal/cmd/observability/scrape-config/create/create.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +34,7 @@ type inputModel struct { Payload *observability.CreateScrapeConfigPayload } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a scrape configuration for an Observability instance", @@ -59,22 +61,22 @@ func NewCmd(p *print.Printer) *cobra.Command { ``, `$ stackit observability scrape-config create --payload @./payload.json --instance-id xxx`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } @@ -87,12 +89,10 @@ func NewCmd(p *print.Printer) *cobra.Command { model.Payload = &defaultPayload } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create scrape configuration %q on Observability instance %q?", *model.Payload.JobName, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create scrape configuration %q on Observability instance %q?", *model.Payload.JobName, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -106,20 +106,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating scrape config") - _, err = wait.CreateScrapeConfigWaitHandler(ctx, apiClient, model.InstanceId, *jobName, model.ProjectId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating scrape config", func() error { + _, err = wait.CreateScrapeConfigWaitHandler(ctx, apiClient, model.InstanceId, *jobName, model.ProjectId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for scrape configuration creation: %w", err) } - s.Stop() } operationState := "Created" if model.Async { operationState = "Triggered creation of" } - p.Outputf("%s scrape configuration with name %q for Observability instance %q\n", operationState, utils.PtrString(jobName), instanceLabel) + params.Printer.Outputf("%s scrape configuration with name %q for Observability instance %q\n", operationState, utils.PtrString(jobName), instanceLabel) return nil }, } @@ -135,7 +135,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} diff --git a/internal/cmd/observability/scrape-config/create/create_test.go b/internal/cmd/observability/scrape-config/create/create_test.go index 54c0573e3..8da9b00a2 100644 --- a/internal/cmd/observability/scrape-config/create/create_test.go +++ b/internal/cmd/observability/scrape-config/create/create_test.go @@ -2,8 +2,11 @@ package create import ( "context" + "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -23,7 +26,7 @@ var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() var testPayload = &observability.CreateScrapeConfigPayload{ - BasicAuth: &observability.CreateScrapeConfigPayloadBasicAuth{ + BasicAuth: &observability.PartialUpdateScrapeConfigsRequestInnerBasicAuth{ Username: utils.Ptr("username"), Password: utils.Ptr("password"), }, @@ -32,9 +35,9 @@ var testPayload = &observability.CreateScrapeConfigPayload{ HonorTimeStamps: utils.Ptr(true), MetricsPath: utils.Ptr("/metrics"), JobName: utils.Ptr("default-name"), - MetricsRelabelConfigs: &[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{ + MetricsRelabelConfigs: &[]observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInner{ { - Action: utils.Ptr("replace"), + Action: observability.PARTIALUPDATESCRAPECONFIGSREQUESTINNERMETRICSRELABELCONFIGSINNERACTION_REPLACE.Ptr(), Modulus: utils.Ptr(1.0), Regex: utils.Ptr("regex"), Replacement: utils.Ptr("replacement"), @@ -48,10 +51,10 @@ var testPayload = &observability.CreateScrapeConfigPayload{ "key2": []interface{}{}, }, SampleLimit: utils.Ptr(1.0), - Scheme: utils.Ptr("scheme"), + Scheme: observability.CREATESCRAPECONFIGPAYLOADSCHEME_HTTPS.Ptr(), ScrapeInterval: utils.Ptr("interval"), ScrapeTimeout: utils.Ptr("timeout"), - StaticConfigs: &[]observability.CreateScrapeConfigPayloadStaticConfigsInner{ + StaticConfigs: &[]observability.PartialUpdateScrapeConfigsRequestInnerStaticConfigsInner{ { Labels: &map[string]interface{}{ "label": "value", @@ -60,7 +63,7 @@ var testPayload = &observability.CreateScrapeConfigPayload{ Targets: &[]string{"target"}, }, }, - TlsConfig: &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{ + TlsConfig: &observability.PartialUpdateScrapeConfigsRequestInnerHttpSdConfigsInnerOauth2TlsConfig{ InsecureSkipVerify: utils.Ptr(true), }, } @@ -69,7 +72,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st flagValues := map[string]string{ projectIdFlag: testProjectId, instanceIdFlag: testInstanceId, - payloadFlag: `{ + payloadFlag: fmt.Sprintf(`{ "jobName": "default-name", "basicAuth": { "username": "username", @@ -95,7 +98,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st "key2": [] }, "sampleLimit": 1.0, - "scheme": "scheme", + "scheme": "%s", "scrapeInterval": "interval", "scrapeTimeout": "timeout", "staticConfigs": [ @@ -110,7 +113,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st "tlsConfig": { "insecureSkipVerify": true } - }`, + }`, observability.CREATESCRAPECONFIGPAYLOADSCHEME_HTTPS), } for _, mod := range mods { mod(flagValues) @@ -145,6 +148,7 @@ func fixtureRequest(mods ...func(request *observability.ApiCreateScrapeConfigReq func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -229,55 +233,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) - 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(nil, 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, - cmpopts.EquateComparable(testCtx), - ) - if diff != "" { - t.Fatalf("Data does not match: %s", diff) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/observability/scrape-config/delete/delete.go b/internal/cmd/observability/scrape-config/delete/delete.go index de04c6fcc..7ec1e8ccf 100644 --- a/internal/cmd/observability/scrape-config/delete/delete.go +++ b/internal/cmd/observability/scrape-config/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -31,7 +33,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", jobNameArg), Short: "Deletes a scrape configuration from an Observability instance", @@ -44,29 +46,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete scrape configuration %q on Observability instance %q? (This cannot be undone)", model.JobName, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete scrape configuration %q on Observability instance %q? (This cannot be undone)", model.JobName, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,20 +78,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting scrape config") - _, err = wait.DeleteScrapeConfigWaitHandler(ctx, apiClient, model.InstanceId, model.JobName, model.ProjectId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting scrape config", func() error { + _, err = wait.DeleteScrapeConfigWaitHandler(ctx, apiClient, model.InstanceId, model.JobName, model.ProjectId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for scrape config deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s scrape configuration with name %q for Observability instance %q\n", operationState, model.JobName, instanceLabel) + params.Printer.Info("%s scrape configuration with name %q for Observability instance %q\n", operationState, model.JobName, instanceLabel) return nil }, } diff --git a/internal/cmd/observability/scrape-config/describe/describe.go b/internal/cmd/observability/scrape-config/describe/describe.go index 2687a72a3..5dc11a619 100644 --- a/internal/cmd/observability/scrape-config/describe/describe.go +++ b/internal/cmd/observability/scrape-config/describe/describe.go @@ -2,12 +2,14 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/observability" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/observability" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", jobNameArg), Short: "Shows details of a scrape configuration from an Observability instance", @@ -48,12 +49,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read scrape configuration: %w", err) } - return outputResult(p, model.OutputFormat, resp.Data) + return outputResult(params.Printer, model.OutputFormat, resp.Data) }, } configureFlags(cmd) @@ -104,24 +105,7 @@ func outputResult(p *print.Printer, outputFormat string, config *observability.J return fmt.Errorf(`config is nil`) } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("marshal scrape configuration: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(config, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal scrape configuration: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, config, func() error { saml2Enabled := "Enabled" if config.Params != nil { saml2 := (*config.Params)["saml2"] @@ -186,5 +170,5 @@ func outputResult(p *print.Printer, outputFormat string, config *observability.J } return nil - } + }) } diff --git a/internal/cmd/observability/scrape-config/describe/describe_test.go b/internal/cmd/observability/scrape-config/describe/describe_test.go index a0cf31413..5f4326b33 100644 --- a/internal/cmd/observability/scrape-config/describe/describe_test.go +++ b/internal/cmd/observability/scrape-config/describe/describe_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -257,7 +259,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.config); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/scrape-config/generate-payload/generate_payload.go b/internal/cmd/observability/scrape-config/generate-payload/generate_payload.go index fe8a9f5ec..e891d728e 100644 --- a/internal/cmd/observability/scrape-config/generate-payload/generate_payload.go +++ b/internal/cmd/observability/scrape-config/generate-payload/generate_payload.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/fileutils" @@ -31,7 +33,7 @@ type inputModel struct { FilePath *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "generate-payload", Short: "Generates a payload to create/update scrape configurations for an Observability instance ", @@ -59,22 +61,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `Generate an Update payload with the values of an existing configuration named "my-config" for Observability instance xxx, and preview it in the terminal`, `$ stackit observability scrape-config generate-payload --job-name my-config --instance-id xxx`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } if model.JobName == nil { createPayload := observabilityUtils.DefaultCreateScrapeConfigPayload - return outputCreateResult(p, model.FilePath, &createPayload) + return outputCreateResult(params.Printer, model.FilePath, &createPayload) } req := buildRequest(ctx, model, apiClient) @@ -88,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("map update scrape config payloads: %w", err) } - return outputUpdateResult(p, model.FilePath, payload) + return outputUpdateResult(params.Printer, model.FilePath, payload) }, } configureFlags(cmd) @@ -101,7 +103,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given file. If unset, writes the payload to the standard output") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) jobName := flags.FlagToStringPointer(p, cmd, jobNameFlag) diff --git a/internal/cmd/observability/scrape-config/generate-payload/generate_payload_test.go b/internal/cmd/observability/scrape-config/generate-payload/generate_payload_test.go index b8740da53..81d3138e3 100644 --- a/internal/cmd/observability/scrape-config/generate-payload/generate_payload_test.go +++ b/internal/cmd/observability/scrape-config/generate-payload/generate_payload_test.go @@ -4,6 +4,10 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -68,6 +72,7 @@ func fixtureRequest(mods ...func(request *observability.ApiGetScrapeConfigReques func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -170,53 +175,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) - 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(nil, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -274,7 +233,7 @@ func TestOutputCreateResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputCreateResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr { @@ -308,7 +267,7 @@ func TestOutputUpdateResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputUpdateResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/scrape-config/list/list.go b/internal/cmd/observability/scrape-config/list/list.go index 5dbdde095..faab36b0a 100644 --- a/internal/cmd/observability/scrape-config/list/list.go +++ b/internal/cmd/observability/scrape-config/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +33,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all scrape configurations of an Observability instance", @@ -50,15 +50,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 scrape configurations of Observability instance "xxx"`, "$ stackit observability scrape-config list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -73,10 +73,10 @@ func NewCmd(p *print.Printer) *cobra.Command { if len(configs) == 0 { instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - p.Info("No scrape configurations found for instance %q\n", instanceLabel) + params.Printer.Info("No scrape configurations found for instance %q\n", instanceLabel) return nil } @@ -85,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { configs = configs[:*model.Limit] } - return outputResult(p, model.OutputFormat, configs) + return outputResult(params.Printer, model.OutputFormat, configs) }, } @@ -101,7 +101,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -128,24 +128,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *observabili } func outputResult(p *print.Printer, outputFormat string, configs []observability.Job) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(configs, "", " ") - if err != nil { - return fmt.Errorf("marshal scrape configurations list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(configs, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal scrape configurations list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, configs, func() error { table := tables.NewTable() table.SetHeader("NAME", "TARGETS", "SCRAPE INTERVAL") for i := range configs { @@ -173,5 +156,5 @@ func outputResult(p *print.Printer, outputFormat string, configs []observability } return nil - } + }) } diff --git a/internal/cmd/observability/scrape-config/list/list_test.go b/internal/cmd/observability/scrape-config/list/list_test.go index 69e5b6ab5..6d4569d71 100644 --- a/internal/cmd/observability/scrape-config/list/list_test.go +++ b/internal/cmd/observability/scrape-config/list/list_test.go @@ -4,6 +4,10 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -11,7 +15,6 @@ import ( "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/observability" ) @@ -62,6 +65,7 @@ func fixtureRequest(mods ...func(request *observability.ApiListScrapeConfigsRequ func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -137,47 +141,7 @@ func TestParseInput(t *testing.T) { 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(nil, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -241,7 +205,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.configs); (err != nil) != tt.wantErr { diff --git a/internal/cmd/observability/scrape-config/scrape_config.go b/internal/cmd/observability/scrape-config/scrape_config.go index f056c6e60..b45cff386 100644 --- a/internal/cmd/observability/scrape-config/scrape_config.go +++ b/internal/cmd/observability/scrape-config/scrape_config.go @@ -8,13 +8,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/observability/scrape-config/list" "github.com/stackitcloud/stackit-cli/internal/cmd/observability/scrape-config/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "scrape-config", Short: "Provides functionality for scrape configurations in Observability", @@ -22,15 +22,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(generatepayload.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(generatepayload.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) } diff --git a/internal/cmd/observability/scrape-config/update/update.go b/internal/cmd/observability/scrape-config/update/update.go index d07e97e1b..130199713 100644 --- a/internal/cmd/observability/scrape-config/update/update.go +++ b/internal/cmd/observability/scrape-config/update/update.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -31,7 +33,7 @@ type inputModel struct { Payload observability.UpdateScrapeConfigPayload } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", jobNameArg), Short: "Updates a scrape configuration of an Observability instance", @@ -56,23 +58,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update scrape configuration %q?", model.JobName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update scrape configuration %q?", model.JobName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -83,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } // The API has no status to wait on, so async mode is default - p.Info("Updated Observability scrape configuration with name %q\n", model.JobName) + params.Printer.Info("Updated Observability scrape configuration with name %q\n", model.JobName) return nil }, } diff --git a/internal/cmd/observability/scrape-config/update/update_test.go b/internal/cmd/observability/scrape-config/update/update_test.go index ad5870231..2f7ce1b13 100644 --- a/internal/cmd/observability/scrape-config/update/update_test.go +++ b/internal/cmd/observability/scrape-config/update/update_test.go @@ -24,7 +24,7 @@ var testInstanceId = uuid.NewString() var testJobName = "my-config" var testPayload = observability.UpdateScrapeConfigPayload{ - BasicAuth: &observability.CreateScrapeConfigPayloadBasicAuth{ + BasicAuth: &observability.PartialUpdateScrapeConfigsRequestInnerBasicAuth{ Username: utils.Ptr("username"), Password: utils.Ptr("password"), }, @@ -32,9 +32,9 @@ var testPayload = observability.UpdateScrapeConfigPayload{ HonorLabels: utils.Ptr(true), HonorTimeStamps: utils.Ptr(true), MetricsPath: utils.Ptr("/metrics"), - MetricsRelabelConfigs: &[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{ + MetricsRelabelConfigs: &[]observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInner{ { - Action: utils.Ptr("replace"), + Action: observability.PARTIALUPDATESCRAPECONFIGSREQUESTINNERMETRICSRELABELCONFIGSINNERACTION_REPLACE.Ptr(), Modulus: utils.Ptr(1.0), Regex: utils.Ptr("regex"), Replacement: utils.Ptr("replacement"), diff --git a/internal/cmd/opensearch/credentials/create/create.go b/internal/cmd/opensearch/credentials/create/create.go index 9a4174db8..cfc9bd5f3 100644 --- a/internal/cmd/opensearch/credentials/create/create.go +++ b/internal/cmd/opensearch/credentials/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/opensearch" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/opensearch/client" opensearchUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/opensearch/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) const ( @@ -30,7 +31,7 @@ type inputModel struct { ShowPassword bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates credentials for an OpenSearch instance", @@ -44,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create credentials for an OpenSearch instance and show the password in the output`, "$ stackit opensearch credentials create --instance-id xxx --show-password"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := opensearchUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create OpenSearch credentials: %w", err) } - return outputResult(p, model.OutputFormat, model.ShowPassword, instanceLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.ShowPassword, instanceLabel, resp) }, } configureFlags(cmd) @@ -93,7 +92,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -105,15 +104,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ShowPassword: flags.FlagToBoolValue(p, cmd, showPasswordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -130,24 +121,8 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, inst if !showPassword { resp.Raw.Credentials.Password = utils.Ptr("hidden") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal OpenSearch credentials: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal OpenSearch credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, utils.PtrString(resp.Id)) // The username field cannot be set by the user so we only display it if it's not returned empty if resp.HasRaw() && resp.Raw.Credentials != nil { @@ -164,5 +139,5 @@ func outputResult(p *print.Printer, outputFormat string, showPassword bool, inst } p.Outputf("URI: %s\n", *resp.Uri) return nil - } + }) } diff --git a/internal/cmd/opensearch/credentials/create/create_test.go b/internal/cmd/opensearch/credentials/create/create_test.go index 1424c9729..b70f26ed2 100644 --- a/internal/cmd/opensearch/credentials/create/create_test.go +++ b/internal/cmd/opensearch/credentials/create/create_test.go @@ -4,17 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/opensearch" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -24,8 +26,8 @@ var testInstanceId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -58,6 +60,7 @@ func fixtureRequest(mods ...func(request *opensearch.ApiCreateCredentialsRequest func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -86,21 +89,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -129,46 +132,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -271,7 +235,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.showPassword, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/opensearch/credentials/credentials.go b/internal/cmd/opensearch/credentials/credentials.go index 4ea7f3f76..e9c878d02 100644 --- a/internal/cmd/opensearch/credentials/credentials.go +++ b/internal/cmd/opensearch/credentials/credentials.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/opensearch/credentials/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/opensearch/credentials/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", Short: "Provides functionality for OpenSearch credentials", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/opensearch/credentials/delete/delete.go b/internal/cmd/opensearch/credentials/delete/delete.go index 1931ad0fc..236b9c626 100644 --- a/internal/cmd/opensearch/credentials/delete/delete.go +++ b/internal/cmd/opensearch/credentials/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialsIdArg), Short: "Deletes credentials of an OpenSearch instance", @@ -43,35 +45,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := opensearchUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } credentialsLabel, err := opensearchUtils.GetCredentialsUsername(ctx, apiClient, model.ProjectId, model.InstanceId, model.CredentialsId) if err != nil { - p.Debug(print.ErrorLevel, "get credentials user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials user name: %v", err) credentialsLabel = model.CredentialsId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,7 +81,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete OpenSearch credentials: %w", err) } - p.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) + params.Printer.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) return nil }, } @@ -110,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/opensearch/credentials/delete/delete_test.go b/internal/cmd/opensearch/credentials/delete/delete_test.go index 4dcdc9dfe..dcaa66ee1 100644 --- a/internal/cmd/opensearch/credentials/delete/delete_test.go +++ b/internal/cmd/opensearch/credentials/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,8 +13,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,8 +33,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -104,7 +102,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +110,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +118,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -164,54 +162,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/opensearch/credentials/describe/describe.go b/internal/cmd/opensearch/credentials/describe/describe.go index fb8012012..50a495940 100644 --- a/internal/cmd/opensearch/credentials/describe/describe.go +++ b/internal/cmd/opensearch/credentials/describe/describe.go @@ -2,10 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" - "github.com/goccy/go-yaml" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +33,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", credentialsIdArg), Short: "Shows details of credentials of an OpenSearch instance", @@ -48,13 +49,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,7 +67,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe OpenSearch credentials: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -94,15 +95,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -116,24 +109,7 @@ func outputResult(p *print.Printer, outputFormat string, credentials *opensearch return fmt.Errorf("credentials is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal OpenSearch credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal OpenSearch credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, credentials, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(credentials.Id)) table.AddSeparator() @@ -146,6 +122,13 @@ func outputResult(p *print.Printer, outputFormat string, credentials *opensearch table.AddRow("PASSWORD", utils.PtrString(credentials.Raw.Credentials.Password)) table.AddSeparator() table.AddRow("URI", utils.PtrString(credentials.Raw.Credentials.Uri)) + table.AddSeparator() + table.AddRow("HOST", utils.PtrString(credentials.Raw.Credentials.Host)) + hosts := credentials.Raw.Credentials.Hosts + if hosts != nil && len(*hosts) > 0 { + table.AddSeparator() + table.AddRow("HOSTS", strings.Join(*hosts, "\n")) + } } err := table.Display(p) if err != nil { @@ -153,5 +136,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials *opensearch } return nil - } + }) } diff --git a/internal/cmd/opensearch/credentials/describe/describe_test.go b/internal/cmd/opensearch/credentials/describe/describe_test.go index 3618a4cc7..2d8ec1a32 100644 --- a/internal/cmd/opensearch/credentials/describe/describe_test.go +++ b/internal/cmd/opensearch/credentials/describe/describe_test.go @@ -4,8 +4,12 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,8 +37,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -104,7 +106,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +114,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +122,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -164,54 +166,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -266,9 +221,38 @@ func TestOutputResult(t *testing.T) { }, wantErr: false, }, + { + name: "host and hosts", + args: args{ + credentials: &opensearch.CredentialsResponse{ + Raw: &opensearch.RawCredentials{ + Credentials: &opensearch.Credentials{ + Host: utils.Ptr("host"), + Hosts: utils.Ptr([]string{ + "hosts-a", + "hosts-b", + }), + }, + }, + }, + }, + }, + { + name: "raw credentials nil host & hosts", + args: args{ + credentials: &opensearch.CredentialsResponse{ + Raw: &opensearch.RawCredentials{ + Credentials: &opensearch.Credentials{ + Host: nil, + Hosts: nil, + }, + }, + }, + }, + }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/opensearch/credentials/list/list.go b/internal/cmd/opensearch/credentials/list/list.go index b58c99796..05f4e8ef8 100644 --- a/internal/cmd/opensearch/credentials/list/list.go +++ b/internal/cmd/opensearch/credentials/list/list.go @@ -2,9 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) @@ -32,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all credentials' IDs for an OpenSearch instance", @@ -49,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 credentials' IDs for an OpenSearch instance`, "$ stackit opensearch credentials list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,22 +68,20 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list OpenSearch credentials: %w", err) } - credentials := *resp.CredentialsList - if len(credentials) == 0 { - instanceLabel, err := opensearchUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) - if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) - instanceLabel = model.InstanceId - } - p.Info("No credentials found for instance %q\n", instanceLabel) - return nil + credentials := resp.GetCredentialsList() + + instanceLabel, err := opensearchUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId } // Truncate output if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentials) + + return outputResult(params.Printer, model.OutputFormat, instanceLabel, credentials) }, } configureFlags(cmd) @@ -98,7 +96,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -118,15 +116,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -135,25 +125,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *opensearch. return req } -func outputResult(p *print.Printer, outputFormat string, credentials []opensearch.CredentialsListItem) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal OpenSearch credentials list: %w", err) +func outputResult(p *print.Printer, outputFormat, instanceLabel string, credentials []opensearch.CredentialsListItem) error { + return p.OutputResult(outputFormat, credentials, func() error { + if len(credentials) == 0 { + p.Outputf("No credentials found for instance %q\n", instanceLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal OpenSearch credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID") for i := range credentials { @@ -166,5 +144,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials []opensearc } return nil - } + }) } diff --git a/internal/cmd/opensearch/credentials/list/list_test.go b/internal/cmd/opensearch/credentials/list/list_test.go index d6b041f57..514606b9e 100644 --- a/internal/cmd/opensearch/credentials/list/list_test.go +++ b/internal/cmd/opensearch/credentials/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,9 +26,9 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -61,6 +62,7 @@ func fixtureRequest(mods ...func(request *opensearch.ApiListCredentialsRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -79,21 +81,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,46 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -210,8 +173,9 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { - outputFormat string - credentials []opensearch.CredentialsListItem + outputFormat string + instanceLabel string + credentials []opensearch.CredentialsListItem } tests := []struct { name string @@ -239,10 +203,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.credentials); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/opensearch/instance/create/create.go b/internal/cmd/opensearch/instance/create/create.go index 62d447f82..776e6b86f 100644 --- a/internal/cmd/opensearch/instance/create/create.go +++ b/internal/cmd/opensearch/instance/create/create.go @@ -2,12 +2,12 @@ package create import ( "context" - "encoding/json" "errors" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -57,7 +57,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates an OpenSearch instance", @@ -74,31 +74,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create an OpenSearch instance with name "my-instance" and specify IP range which is allowed to access it`, "$ stackit opensearch instance create --name my-instance --plan-id xxx --acl 1.2.3.0/24"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create an OpenSearch instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create an OpenSearch instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -118,16 +116,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for OpenSearch instance creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, projectLabel, instanceId, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, instanceId, resp) }, } configureFlags(cmd) @@ -152,7 +150,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -189,15 +187,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -257,29 +247,12 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient openSearchCl } func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel, instanceId string, resp *opensearch.CreateInstanceResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal OpenSearch instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal OpenSearch instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, instanceId) return nil - } + }) } diff --git a/internal/cmd/opensearch/instance/create/create_test.go b/internal/cmd/opensearch/instance/create/create_test.go index 3aceed814..27822b653 100644 --- a/internal/cmd/opensearch/instance/create/create_test.go +++ b/internal/cmd/opensearch/instance/create/create_test.go @@ -5,6 +5,10 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -15,8 +19,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -44,17 +46,17 @@ var testMonitoringInstanceId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceNameFlag: "example-name", - enableMonitoringFlag: "true", - graphiteFlag: "example-graphite", - metricsFrequencyFlag: "100", - metricsPrefixFlag: "example-prefix", - monitoringInstanceIdFlag: testMonitoringInstanceId, - pluginFlag: "example-plugin", - sgwAclFlag: "198.51.100.14/24", - syslogFlag: "example-syslog", - planIdFlag: testPlanId, + globalflags.ProjectIdFlag: testProjectId, + instanceNameFlag: "example-name", + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringInstanceId, + pluginFlag: "example-plugin", + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, } for _, mod := range mods { mod(flagValues) @@ -110,6 +112,7 @@ func fixtureRequest(mods ...func(request *opensearch.ApiCreateInstanceRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string sgwAclValues []string pluginValues []string @@ -145,9 +148,9 @@ func TestParseInput(t *testing.T) { { description: "required fields only", flagValues: map[string]string{ - projectIdFlag: testProjectId, - instanceNameFlag: "example-name", - planIdFlag: testPlanId, + globalflags.ProjectIdFlag: testProjectId, + instanceNameFlag: "example-name", + planIdFlag: testPlanId, }, isValid: true, expectedModel: &inputModel{ @@ -162,13 +165,13 @@ func TestParseInput(t *testing.T) { { description: "zero values", flagValues: map[string]string{ - projectIdFlag: testProjectId, - planIdFlag: testPlanId, - instanceNameFlag: "", - enableMonitoringFlag: "false", - graphiteFlag: "", - metricsFrequencyFlag: "0", - metricsPrefixFlag: "", + globalflags.ProjectIdFlag: testProjectId, + planIdFlag: testPlanId, + instanceNameFlag: "", + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", }, isValid: true, expectedModel: &inputModel{ @@ -187,21 +190,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -276,76 +279,11 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.sgwAclValues { - err := cmd.Flags().Set(sgwAclFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) - } - } - - for _, value := range tt.pluginValues { - err := cmd.Flags().Set(pluginFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", pluginFlag, value, err) - } - } - - for _, value := range tt.syslogValues { - err := cmd.Flags().Set(syslogFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) - } - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + sgwAclFlag: tt.sgwAclValues, + pluginFlag: tt.pluginValues, + syslogFlag: tt.syslogValues, + }, tt.isValid) }) } } @@ -516,7 +454,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.instanceId, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/opensearch/instance/delete/delete.go b/internal/cmd/opensearch/instance/delete/delete.go index 40dc60461..58f4f59d1 100644 --- a/internal/cmd/opensearch/instance/delete/delete.go +++ b/internal/cmd/opensearch/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes an OpenSearch instance", @@ -41,29 +43,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := opensearchUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -75,20 +75,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for OpenSearch instance deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -108,15 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/opensearch/instance/delete/delete_test.go b/internal/cmd/opensearch/instance/delete/delete_test.go index 7454c04e0..f9943acf7 100644 --- a/internal/cmd/opensearch/instance/delete/delete_test.go +++ b/internal/cmd/opensearch/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,8 +13,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -34,7 +32,7 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, } for _, mod := range mods { mod(flagValues) @@ -101,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +115,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/opensearch/instance/describe/describe.go b/internal/cmd/opensearch/instance/describe/describe.go index 1358aef39..663ce82de 100644 --- a/internal/cmd/opensearch/instance/describe/describe.go +++ b/internal/cmd/opensearch/instance/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of an OpenSearch instance", @@ -46,12 +46,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read OpenSearch instance: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -82,15 +82,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -104,24 +96,7 @@ func outputResult(p *print.Printer, outputFormat string, instance *opensearch.In return fmt.Errorf("instance is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal OpenSearch instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal OpenSearch instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(instance.InstanceId)) table.AddSeparator() @@ -151,5 +126,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *opensearch.In } return nil - } + }) } diff --git a/internal/cmd/opensearch/instance/describe/describe_test.go b/internal/cmd/opensearch/instance/describe/describe_test.go index d4b58656f..a90f5d142 100644 --- a/internal/cmd/opensearch/instance/describe/describe_test.go +++ b/internal/cmd/opensearch/instance/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,8 +16,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -34,7 +35,7 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, } for _, mod := range mods { mod(flagValues) @@ -101,7 +102,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +110,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +118,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -241,7 +195,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/opensearch/instance/instance.go b/internal/cmd/opensearch/instance/instance.go index 649d439ce..d8f58a668 100644 --- a/internal/cmd/opensearch/instance/instance.go +++ b/internal/cmd/opensearch/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/opensearch/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/opensearch/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for OpenSearch instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/opensearch/instance/list/list.go b/internal/cmd/opensearch/instance/list/list.go index 024619beb..48f7e986b 100644 --- a/internal/cmd/opensearch/instance/list/list.go +++ b/internal/cmd/opensearch/instance/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/opensearch" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/opensearch/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all OpenSearch instances", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 OpenSearch instances`, "$ stackit opensearch instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,15 +66,12 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get OpenSearch instances: %w", err) } - instances := *resp.Instances - if len(instances) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No instances found for project %q\n", projectLabel) - return nil + instances := resp.GetInstances() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } // Truncate output @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) }, } @@ -93,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *opensearch. return req } -func outputResult(p *print.Printer, outputFormat string, instances []opensearch.Instance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal OpenSearch instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal OpenSearch instance list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []opensearch.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE") for i := range instances { @@ -165,5 +143,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []opensearch. } return nil - } + }) } diff --git a/internal/cmd/opensearch/instance/list/list_test.go b/internal/cmd/opensearch/instance/list/list_test.go index baa22ac9f..910c0fab3 100644 --- a/internal/cmd/opensearch/instance/list/list_test.go +++ b/internal/cmd/opensearch/instance/list/list_test.go @@ -4,19 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,8 +25,8 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -59,6 +59,7 @@ func fixtureRequest(mods ...func(request *opensearch.ApiListInstancesRequest)) o func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +78,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -113,48 +114,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -190,6 +150,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string instances []opensearch.Instance } tests := []struct { @@ -218,10 +179,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/opensearch/instance/update/update.go b/internal/cmd/opensearch/instance/update/update.go index 57072b0a8..557fb1712 100644 --- a/internal/cmd/opensearch/instance/update/update.go +++ b/internal/cmd/opensearch/instance/update/update.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -56,7 +58,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates an OpenSearch instance", @@ -72,29 +74,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := opensearchUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -114,20 +114,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for OpenSearch instance update: %w", err) } - s.Stop() } operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -199,15 +199,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/opensearch/instance/update/update_test.go b/internal/cmd/opensearch/instance/update/update_test.go index 478199985..3d7b291d2 100644 --- a/internal/cmd/opensearch/instance/update/update_test.go +++ b/internal/cmd/opensearch/instance/update/update_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -15,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -57,16 +57,16 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - enableMonitoringFlag: "true", - graphiteFlag: "example-graphite", - metricsFrequencyFlag: "100", - metricsPrefixFlag: "example-prefix", - monitoringInstanceIdFlag: testMonitoringInstanceId, - pluginFlag: "example-plugin", - sgwAclFlag: "198.51.100.14/24", - syslogFlag: "example-syslog", - planIdFlag: testPlanId, + globalflags.ProjectIdFlag: testProjectId, + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringInstanceId, + pluginFlag: "example-plugin", + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, } for _, mod := range mods { mod(flagValues) @@ -158,7 +158,7 @@ func TestParseInput(t *testing.T) { description: "required flags only (no values to update)", argValues: fixtureArgValues(), flagValues: map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, }, isValid: false, expectedModel: &inputModel{ @@ -173,12 +173,12 @@ func TestParseInput(t *testing.T) { description: "zero values", argValues: fixtureArgValues(), flagValues: map[string]string{ - projectIdFlag: testProjectId, - planIdFlag: testPlanId, - enableMonitoringFlag: "false", - graphiteFlag: "", - metricsFrequencyFlag: "0", - metricsPrefixFlag: "", + globalflags.ProjectIdFlag: testProjectId, + planIdFlag: testPlanId, + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", }, isValid: true, expectedModel: &inputModel{ @@ -198,7 +198,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -206,7 +206,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -214,7 +214,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -294,7 +294,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/opensearch/opensearch.go b/internal/cmd/opensearch/opensearch.go index 08103e63c..96d02fd3e 100644 --- a/internal/cmd/opensearch/opensearch.go +++ b/internal/cmd/opensearch/opensearch.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/opensearch/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/opensearch/plans" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "opensearch", Short: "Provides functionality for OpenSearch", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(plans.NewCmd(p)) - cmd.AddCommand(credentials.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) } diff --git a/internal/cmd/opensearch/plans/plans.go b/internal/cmd/opensearch/plans/plans.go index 013e486e1..f5705879e 100644 --- a/internal/cmd/opensearch/plans/plans.go +++ b/internal/cmd/opensearch/plans/plans.go @@ -2,11 +2,13 @@ package plans import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/opensearch" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/opensearch/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "plans", Short: "Lists all OpenSearch service plans", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 OpenSearch service plans`, "$ stackit opensearch plans --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,15 +66,12 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get OpenSearch service plans: %w", err) } - plans := *resp.Offerings - if len(plans) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No plans found for project %q\n", projectLabel) - return nil + plans := resp.GetOfferings() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } // Truncate output @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { plans = plans[:*model.Limit] } - return outputResult(p, model.OutputFormat, plans) + return outputResult(params.Printer, model.OutputFormat, projectLabel, plans) }, } @@ -93,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *opensearch. return req } -func outputResult(p *print.Printer, outputFormat string, plans []opensearch.Offering) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(plans, "", " ") - if err != nil { - return fmt.Errorf("marshal OpenSearch plans: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, plans []opensearch.Offering) error { + return p.OutputResult(outputFormat, plans, func() error { + if len(plans) == 0 { + p.Outputf("No plans found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(plans, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal OpenSearch plans: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { @@ -173,5 +151,5 @@ func outputResult(p *print.Printer, outputFormat string, plans []opensearch.Offe } return nil - } + }) } diff --git a/internal/cmd/opensearch/plans/plans_test.go b/internal/cmd/opensearch/plans/plans_test.go index a1d162d4a..aedcd19b3 100644 --- a/internal/cmd/opensearch/plans/plans_test.go +++ b/internal/cmd/opensearch/plans/plans_test.go @@ -4,19 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/opensearch" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -25,8 +25,8 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -59,6 +59,7 @@ func fixtureRequest(mods ...func(request *opensearch.ApiListOfferingsRequest)) o func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +78,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -113,48 +114,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -190,6 +150,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string plans []opensearch.Offering } tests := []struct { @@ -218,10 +179,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.plans); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.plans); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/organization/describe/describe.go b/internal/cmd/organization/describe/describe.go new file mode 100644 index 000000000..74ad1a2dc --- /dev/null +++ b/internal/cmd/organization/describe/describe.go @@ -0,0 +1,120 @@ +package describe + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + organizationIdArg = "ORGANIZATION_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + OrganizationId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Show an organization", + Long: "Show an organization.", + // the arg can be the organization uuid or the container id, which is not a uuid, so no validation needed + Args: args.SingleArg(organizationIdArg, nil), + Example: examples.Build( + examples.NewExample( + `Describe the organization with the organization uuid "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"`, + "$ stackit organization describe xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + ), + examples.NewExample( + `Describe the organization with the container id "foo-bar-organization"`, + "$ stackit organization describe foo-bar-organization", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return err + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + organizationId := inputArgs[0] + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: organizationId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *resourcemanager.APIClient) resourcemanager.ApiGetOrganizationRequest { + req := apiClient.GetOrganization(ctx, model.OrganizationId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, organization *resourcemanager.OrganizationResponse) error { + return p.OutputResult(outputFormat, organization, func() error { + if organization == nil { + p.Outputln("show organization: empty response") + return nil + } + + table := tables.NewTable() + + table.AddRow("ORGANIZATION ID", utils.PtrString(organization.OrganizationId)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(organization.Name)) + table.AddSeparator() + table.AddRow("CONTAINER ID", utils.PtrString(organization.ContainerId)) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(organization.LifecycleState)) + table.AddSeparator() + table.AddRow("CREATION TIME", utils.PtrString(organization.CreationTime)) + table.AddSeparator() + table.AddRow("UPDATE TIME", utils.PtrString(organization.UpdateTime)) + table.AddSeparator() + table.AddRow("LABELS", utils.JoinStringMap(utils.PtrValue(organization.Labels), ": ", ", ")) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/organization/describe/describe_test.go b/internal/cmd/organization/describe/describe_test.go new file mode 100644 index 000000000..00d1d2598 --- /dev/null +++ b/internal/cmd/organization/describe/describe_test.go @@ -0,0 +1,190 @@ +package describe + +import ( + "context" + "testing" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "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" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{} + +var ( + testOrganizationId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testOrganizationId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + OrganizationId: testOrganizationId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiGetOrganizationRequest)) resourcemanager.ApiGetOrganizationRequest { + request := testClient.GetOrganization(testCtx, testOrganizationId) + 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(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "uuid as example for an organization id", + argValues: []string{"12345678-90ab-cdef-1234-1234567890ab"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.OrganizationId = "12345678-90ab-cdef-1234-1234567890ab" + }), + }, + { + description: "non uuid string as example for a container id", + argValues: []string{"foo-bar-organization"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.OrganizationId = "foo-bar-organization" + }), + }, + { + description: "no args", + argValues: []string{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest resourcemanager.ApiGetOrganizationRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + organization *resourcemanager.OrganizationResponse + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "nil pointer as organization", + args: args{ + organization: nil, + }, + wantErr: false, + }, + { + name: "empty organization", + args: args{ + organization: utils.Ptr(resourcemanager.OrganizationResponse{}), + }, + wantErr: false, + }, + { + name: "full response", + args: args{ + organization: utils.Ptr(resourcemanager.OrganizationResponse{ + OrganizationId: utils.Ptr(uuid.NewString()), + Name: utils.Ptr("foo bar"), + LifecycleState: utils.Ptr(resourcemanager.LIFECYCLESTATE_ACTIVE), + ContainerId: utils.Ptr("foo-bar-organization"), + CreationTime: utils.Ptr(time.Now()), + UpdateTime: utils.Ptr(time.Now()), + Labels: utils.Ptr(map[string]string{ + "foo": "true", + "bar": "false", + }), + }), + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.organization); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/organization/list/list.go b/internal/cmd/organization/list/list.go new file mode 100644 index 000000000..050d265f0 --- /dev/null +++ b/internal/cmd/organization/list/list.go @@ -0,0 +1,146 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 + Member string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all organizations", + Long: "Lists all organizations.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists organizations for your user`, + "$ stackit organization list", + ), + examples.NewExample( + `Lists the first 10 organizations`, + "$ stackit organization list --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + activeProfile, err := config.GetProfile() + if err != nil { + return fmt.Errorf("get profile: %w", err) + } + model.Member = auth.GetProfileEmail(activeProfile) + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return err + } + + if resp == nil { + return fmt.Errorf("list organizations: empty response") + } + + return outputResult(params.Printer, model.OutputFormat, utils.PtrValue(resp.Items)) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list (default 50)") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && (*limit < 1 || *limit > 100) { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be between 0 and 100", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *resourcemanager.APIClient) resourcemanager.ApiListOrganizationsRequest { + req := apiClient.ListOrganizations(ctx) + req = req.Member(model.Member) + if model.Limit != nil { + req = req.Limit(float32(*model.Limit)) + } + return req +} + +func outputResult(p *print.Printer, outputFormat string, organizations []resourcemanager.ListOrganizationsResponseItemsInner) error { + return p.OutputResult(outputFormat, organizations, func() error { + if len(organizations) == 0 { + p.Outputln("No organizations found") + return nil + } + + table := tables.NewTable() + table.SetHeader("ID", "NAME", "CONTAINER ID") + + for _, organization := range organizations { + table.AddRow( + utils.PtrString(organization.OrganizationId), + utils.PtrString(organization.Name), + utils.PtrString(organization.ContainerId), + ) + table.AddSeparator() + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/organization/list/list_test.go b/internal/cmd/organization/list/list_test.go new file mode 100644 index 000000000..ec54c69ea --- /dev/null +++ b/internal/cmd/organization/list/list_test.go @@ -0,0 +1,191 @@ +package list + +import ( + "context" + "strconv" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{} + +const ( + testEmail = "foo@bar" + testLimit = 10 +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + limitFlag: strconv.Itoa(int(testLimit)), + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(testLimit)), + Member: testEmail, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiListOrganizationsRequest)) resourcemanager.ApiListOrganizationsRequest { + request := testClient.ListOrganizations(testCtx) + request = request.Limit(testLimit) + request = request.Member(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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + // model.Member is set by the Run function afterwards + model.Member = "" + }), + }, + { + description: "no limit", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + // model.Member is set by the Run function afterwards + model.Member = "" + model.Limit = nil + }), + }, + { + 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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest resourcemanager.ApiListOrganizationsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "empty input model", + model: fixtureInputModel(func(model *inputModel) { + model.Member = "" + model.Limit = nil + }), + expectedRequest: testClient.ListOrganizations(testCtx).Member(""), + }, + } + + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + organizations []resourcemanager.ListOrganizationsResponseItemsInner + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: false, + }, + { + name: "empty organizations slice", + args: args{ + organizations: []resourcemanager.ListOrganizationsResponseItemsInner{}, + }, + wantErr: false, + }, + { + name: "empty organization in organizations slice", + args: args{ + organizations: []resourcemanager.ListOrganizationsResponseItemsInner{{}}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.organizations); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/organization/member/add/add.go b/internal/cmd/organization/member/add/add.go index b03ae495f..325669ae3 100644 --- a/internal/cmd/organization/member/add/add.go +++ b/internal/cmd/organization/member/add/add.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -12,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/authorization/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/authorization" ) const ( @@ -32,7 +35,7 @@ type inputModel struct { Role *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("add %s", subjectArg), Short: "Adds a member to an organization", @@ -51,21 +54,19 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, 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 %q?", *model.Role, model.Subject, *model.OrganizationId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to add the %s role to %s on organization with ID %q?", *model.Role, model.Subject, *model.OrganizationId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -77,7 +78,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("add member: %w", err) } - p.Info("Member added") + params.Printer.Info("Member added") return nil }, } @@ -105,15 +106,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Role: flags.FlagToStringPointer(p, cmd, roleFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/organization/member/add/add_test.go b/internal/cmd/organization/member/add/add_test.go index 996439285..dfe3300f0 100644 --- a/internal/cmd/organization/member/add/add_test.go +++ b/internal/cmd/organization/member/add/add_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -124,54 +124,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/organization/member/list/list.go b/internal/cmd/organization/member/list/list.go index e3376d584..fef787e39 100644 --- a/internal/cmd/organization/member/list/list.go +++ b/internal/cmd/organization/member/list/list.go @@ -2,12 +2,14 @@ package list import ( "context" - "encoding/json" "fmt" "sort" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/authorization/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/authorization" ) const ( @@ -38,7 +39,7 @@ type inputModel struct { SortBy string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists members of an organization", @@ -55,15 +56,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 members of an organization`, "$ stackit organization member list --organization-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -76,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } members := *resp.Members if len(members) == 0 { - p.Info("No members found for organization with ID %q\n", *model.OrganizationId) + params.Printer.Info("No members found for organization with ID %q\n", *model.OrganizationId) return nil } @@ -85,7 +86,7 @@ func NewCmd(p *print.Printer) *cobra.Command { members = members[:*model.Limit] } - return outputResult(p, model.OutputFormat, model.SortBy, members) + return outputResult(params.Printer, model.OutputFormat, model.SortBy, members) }, } configureFlags(cmd) @@ -104,7 +105,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) @@ -123,15 +124,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { SortBy: flags.FlagWithDefaultToStringValue(p, cmd, sortByFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -156,25 +149,7 @@ func outputResult(p *print.Printer, outputFormat, sortBy string, members []autho } sort.SliceStable(members, sortFn) - switch outputFormat { - case print.JSONOutputFormat: - // Show details - details, err := json.MarshalIndent(members, "", " ") - if err != nil { - return fmt.Errorf("marshal members: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(members, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal members: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, members, func() error { table := tables.NewTable() table.SetHeader("SUBJECT", "ROLE") for i := range members { @@ -186,9 +161,10 @@ func outputResult(p *print.Printer, outputFormat, sortBy string, members []autho table.AddRow(utils.PtrString(m.Subject), utils.PtrString(m.Role)) } - if sortBy == "subject" { + switch sortBy { + case "subject": table.EnableAutoMergeOnColumns(1) - } else if sortBy == "role" { + case "role": table.EnableAutoMergeOnColumns(2) } @@ -198,5 +174,5 @@ func outputResult(p *print.Printer, outputFormat, sortBy string, members []autho } return nil - } + }) } diff --git a/internal/cmd/organization/member/list/list_test.go b/internal/cmd/organization/member/list/list_test.go index 2c73681c0..675cbd787 100644 --- a/internal/cmd/organization/member/list/list_test.go +++ b/internal/cmd/organization/member/list/list_test.go @@ -4,13 +4,15 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/authorization" ) @@ -55,6 +57,7 @@ func fixtureRequest(mods ...func(request *authorization.ApiListMembersRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -124,48 +127,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -237,7 +199,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.sortBy, tt.args.members); (err != nil) != tt.wantErr { diff --git a/internal/cmd/organization/member/member.go b/internal/cmd/organization/member/member.go index cf7c515d8..bc4a8b200 100644 --- a/internal/cmd/organization/member/member.go +++ b/internal/cmd/organization/member/member.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/organization/member/list" "github.com/stackitcloud/stackit-cli/internal/cmd/organization/member/remove" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "member", Short: "Manages organization members", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(add.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(remove.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(add.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(remove.NewCmd(params)) } diff --git a/internal/cmd/organization/member/remove/remove.go b/internal/cmd/organization/member/remove/remove.go index de9a09ceb..27e95be67 100644 --- a/internal/cmd/organization/member/remove/remove.go +++ b/internal/cmd/organization/member/remove/remove.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -35,7 +37,7 @@ type inputModel struct { Force bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("remove %s", subjectArg), Short: "Removes a member from an organization", @@ -55,26 +57,24 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) 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 %q?", *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 = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to remove the %s role from %s on organization with ID %q?", *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 = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,7 +84,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("remove member: %w", err) } - p.Info("Member removed") + params.Printer.Info("Member removed") return nil }, } @@ -114,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Force: flags.FlagToBoolValue(p, cmd, forceFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/organization/member/remove/remove_test.go b/internal/cmd/organization/member/remove/remove_test.go index fff1648f8..81f1a368c 100644 --- a/internal/cmd/organization/member/remove/remove_test.go +++ b/internal/cmd/organization/member/remove/remove_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -137,54 +137,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/organization/organization.go b/internal/cmd/organization/organization.go index 3d34090fd..adc9fa8e8 100644 --- a/internal/cmd/organization/organization.go +++ b/internal/cmd/organization/organization.go @@ -3,16 +3,19 @@ package organization import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/cmd/organization/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/organization/list" "github.com/stackitcloud/stackit-cli/internal/cmd/organization/member" "github.com/stackitcloud/stackit-cli/internal/cmd/organization/role" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "organization", Short: "Manages organizations", @@ -23,11 +26,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(member.NewCmd(p)) - cmd.AddCommand(role.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(member.NewCmd(params)) + cmd.AddCommand(role.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) } diff --git a/internal/cmd/organization/role/list/list.go b/internal/cmd/organization/role/list/list.go index c9e175323..f9e7e4bf7 100644 --- a/internal/cmd/organization/role/list/list.go +++ b/internal/cmd/organization/role/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/authorization/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/authorization" ) const ( @@ -33,7 +34,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists roles and permissions of an organization", @@ -50,15 +51,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 roles and permissions of an organization`, "$ stackit organization role list --organization-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -71,7 +72,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } roles := *resp.Roles if len(roles) == 0 { - p.Info("No roles found for organization with ID %q\n", *model.OrganizationId) + params.Printer.Info("No roles found for organization with ID %q\n", *model.OrganizationId) return nil } @@ -80,7 +81,7 @@ func NewCmd(p *print.Printer) *cobra.Command { roles = roles[:*model.Limit] } - return outputRolesResult(p, model.OutputFormat, roles) + return outputRolesResult(params.Printer, model.OutputFormat, roles) }, } configureFlags(cmd) @@ -95,7 +96,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) @@ -112,15 +113,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +122,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *authorizati } func outputRolesResult(p *print.Printer, outputFormat string, roles []authorization.Role) error { - switch outputFormat { - case print.JSONOutputFormat: - // Show details - details, err := json.MarshalIndent(roles, "", " ") - if err != nil { - return fmt.Errorf("marshal roles: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(roles, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal roles: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, roles, func() error { table := tables.NewTable() table.SetHeader("ROLE NAME", "ROLE DESCRIPTION", "PERMISSION NAME", "PERMISSION DESCRIPTION") for i := range roles { @@ -172,5 +147,5 @@ func outputRolesResult(p *print.Printer, outputFormat string, roles []authorizat } return nil - } + }) } diff --git a/internal/cmd/organization/role/list/list_test.go b/internal/cmd/organization/role/list/list_test.go index 1268fb039..5396717d0 100644 --- a/internal/cmd/organization/role/list/list_test.go +++ b/internal/cmd/organization/role/list/list_test.go @@ -4,13 +4,15 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/authorization" ) @@ -54,6 +56,7 @@ func fixtureRequest(mods ...func(request *authorization.ApiListRolesRequest)) au func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -94,48 +97,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -199,7 +161,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputRolesResult(p, tt.args.outputFormat, tt.args.roles); (err != nil) != tt.wantErr { diff --git a/internal/cmd/organization/role/role.go b/internal/cmd/organization/role/role.go index f32189bf5..d3146aca8 100644 --- a/internal/cmd/organization/role/role.go +++ b/internal/cmd/organization/role/role.go @@ -3,13 +3,13 @@ package role import ( "github.com/stackitcloud/stackit-cli/internal/cmd/organization/role/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "role", Short: "Manages organization roles", @@ -17,10 +17,10 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/postgresflex/backup/backup.go b/internal/cmd/postgresflex/backup/backup.go index 85b08b5b2..f6ad7c518 100644 --- a/internal/cmd/postgresflex/backup/backup.go +++ b/internal/cmd/postgresflex/backup/backup.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backup/list" updateschedule "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backup/update-schedule" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "backup", Short: "Provides functionality for PostgreSQL Flex instance backups", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(updateschedule.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(updateschedule.NewCmd(params)) } diff --git a/internal/cmd/postgresflex/backup/describe/describe.go b/internal/cmd/postgresflex/backup/describe/describe.go index dbb8fe4ce..ee3e3753e 100644 --- a/internal/cmd/postgresflex/backup/describe/describe.go +++ b/internal/cmd/postgresflex/backup/describe/describe.go @@ -2,12 +2,14 @@ package describe import ( "context" - "encoding/json" "fmt" "time" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) const ( @@ -37,7 +38,7 @@ type inputModel struct { BackupId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", backupIdArg), Short: "Shows details of a backup for a PostgreSQL Flex instance", @@ -53,13 +54,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(backupIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -72,7 +73,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe backup for PostgreSQL Flex instance: %w", err) } - return outputResult(p, model.OutputFormat, *resp.Item) + return outputResult(params.Printer, model.OutputFormat, *resp.Item) }, } configureFlags(cmd) @@ -116,24 +117,7 @@ func outputResult(p *print.Printer, outputFormat string, backup postgresflex.Bac } backupExpireDate := backupStartTime.AddDate(backupExpireYearOffset, backupExpireMonthOffset, backupExpireDayOffset).Format(time.DateOnly) - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(backup, "", " ") - if err != nil { - return fmt.Errorf("marshal backup for PostgreSQL Flex backup: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(backup, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal backup for PostgreSQL Flex backup: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, backup, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(backup.Id)) table.AddSeparator() @@ -151,5 +135,5 @@ func outputResult(p *print.Printer, outputFormat string, backup postgresflex.Bac } return nil - } + }) } diff --git a/internal/cmd/postgresflex/backup/describe/describe_test.go b/internal/cmd/postgresflex/backup/describe/describe_test.go index 5c3479254..4d9016ca5 100644 --- a/internal/cmd/postgresflex/backup/describe/describe_test.go +++ b/internal/cmd/postgresflex/backup/describe/describe_test.go @@ -5,13 +5,16 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} @@ -262,7 +265,7 @@ func Test_outputResult(t *testing.T) { }}, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/backup/list/list.go b/internal/cmd/postgresflex/backup/list/list.go index 7e1d6c398..004159b2b 100644 --- a/internal/cmd/postgresflex/backup/list/list.go +++ b/internal/cmd/postgresflex/backup/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -38,7 +38,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all backups which are available for a PostgreSQL Flex instance", @@ -55,22 +55,22 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit postgresflex backup list --instance-id xxx --limit 10"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, *model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = *model.InstanceId } @@ -78,10 +78,10 @@ func NewCmd(p *print.Printer) *cobra.Command { req := buildRequest(ctx, model, apiClient) resp, err := req.Execute() if err != nil { - return fmt.Errorf("get backups for PostgreSQL Flex instance %q: %w\n", instanceLabel, err) + return fmt.Errorf("get backups for PostgreSQL Flex instance %q: %w", instanceLabel, err) } if resp.Items == nil || len(*resp.Items) == 0 { - cmd.Printf("No backups found for instance %q\n", instanceLabel) + params.Printer.Outputf("No backups found for instance %q", instanceLabel) return nil } backups := *resp.Items @@ -91,7 +91,7 @@ func NewCmd(p *print.Printer) *cobra.Command { backups = backups[:*model.Limit] } - return outputResult(p, model.OutputFormat, backups) + return outputResult(params.Printer, model.OutputFormat, backups) }, } @@ -107,7 +107,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -134,24 +134,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresfle } func outputResult(p *print.Printer, outputFormat string, backups []postgresflex.Backup) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(backups, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex backup list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(backups, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex backup list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, backups, func() error { table := tables.NewTable() table.SetHeader("ID", "CREATED AT", "EXPIRES AT", "BACKUP SIZE") for i := range backups { @@ -177,5 +160,5 @@ func outputResult(p *print.Printer, outputFormat string, backups []postgresflex. } return nil - } + }) } diff --git a/internal/cmd/postgresflex/backup/list/list_test.go b/internal/cmd/postgresflex/backup/list/list_test.go index f34d184cf..3f6384ae8 100644 --- a/internal/cmd/postgresflex/backup/list/list_test.go +++ b/internal/cmd/postgresflex/backup/list/list_test.go @@ -5,13 +5,18 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} @@ -62,6 +67,7 @@ func fixtureRequest(mods ...func(request *postgresflex.ApiListBackupsRequest)) p func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -137,45 +143,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) - 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(nil, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -243,7 +211,7 @@ func Test_outputResult(t *testing.T) { }, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/backup/update-schedule/update_schedule.go b/internal/cmd/postgresflex/backup/update-schedule/update_schedule.go index 9d5ff6626..447fe0864 100644 --- a/internal/cmd/postgresflex/backup/update-schedule/update_schedule.go +++ b/internal/cmd/postgresflex/backup/update-schedule/update_schedule.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) const ( @@ -28,7 +31,7 @@ type inputModel struct { BackupSchedule *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "update-schedule", Short: "Updates backup schedule for a PostgreSQL Flex instance", @@ -40,32 +43,30 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit postgresflex backup update-schedule --instance-id xxx --schedule '6 6 * * *'"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, *model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = *model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update backup schedule of instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update backup schedule of instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -91,7 +92,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} diff --git a/internal/cmd/postgresflex/backup/update-schedule/update_schedule_test.go b/internal/cmd/postgresflex/backup/update-schedule/update_schedule_test.go index 446f96226..18ed95ae6 100644 --- a/internal/cmd/postgresflex/backup/update-schedule/update_schedule_test.go +++ b/internal/cmd/postgresflex/backup/update-schedule/update_schedule_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -73,6 +74,7 @@ func fixtureRequest(mods ...func(request *postgresflex.ApiUpdateBackupScheduleRe func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -142,45 +144,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd(nil) - 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(nil, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go index 1d753cfeb..facfdf105 100644 --- a/internal/cmd/postgresflex/instance/clone/clone.go +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -2,10 +2,10 @@ package clone import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -40,7 +40,7 @@ type inputModel struct { RecoveryDate *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("clone %s", instanceIdArg), Short: "Clones a PostgreSQL Flex instance", @@ -61,29 +61,27 @@ func NewCmd(p *print.Printer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to clone instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to clone instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -99,16 +97,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Cloning instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Cloning instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for PostgreSQL Flex instance cloning: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, instanceLabel, instanceId, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, instanceLabel, instanceId, resp) }, } configureFlags(cmd) @@ -149,15 +147,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu RecoveryDate: utils.Ptr(recoveryTimestampString), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -209,29 +199,13 @@ func outputResult(p *print.Printer, outputFormat string, async bool, instanceLab if resp == nil { return fmt.Errorf("response not set") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgresFlex instance clone: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgresFlex instance clone: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Cloned" if async { operationState = "Triggered cloning of" } p.Info("%s instance from instance %q. New Instance ID: %s\n", operationState, instanceLabel, instanceId) return nil - } + }) } diff --git a/internal/cmd/postgresflex/instance/clone/clone_test.go b/internal/cmd/postgresflex/instance/clone/clone_test.go index eed05d8d3..ebe60a424 100644 --- a/internal/cmd/postgresflex/instance/clone/clone_test.go +++ b/internal/cmd/postgresflex/instance/clone/clone_test.go @@ -6,13 +6,17 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} @@ -301,54 +305,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -553,7 +510,7 @@ func Test_outputResult(t *testing.T) { }, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/instance/create/create.go b/internal/cmd/postgresflex/instance/create/create.go index 191d982f5..7cad848d6 100644 --- a/internal/cmd/postgresflex/instance/create/create.go +++ b/internal/cmd/postgresflex/instance/create/create.go @@ -2,11 +2,11 @@ package create import ( "context" - "encoding/json" "errors" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -57,7 +57,7 @@ type inputModel struct { Type *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a PostgreSQL Flex instance", @@ -74,32 +74,30 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a PostgreSQL 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 postgresflex instance create --name my-instance --cpu 2 --ram 4 --acl 1.2.3.0/24 --storage-size 20`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a PostgreSQL Flex instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a PostgreSQL Flex instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Fill in version, if needed @@ -124,16 +122,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for PostgreSQL Flex instance creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, projectLabel, instanceId, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, instanceId, resp) }, } configureFlags(cmd) @@ -158,7 +156,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -195,15 +193,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Type: utils.Ptr(flags.FlagWithDefaultToStringValue(p, cmd, typeFlag)), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -277,29 +267,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, projectLabe if resp == nil { return fmt.Errorf("no response passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgresFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgresFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, instanceId) return nil - } + }) } diff --git a/internal/cmd/postgresflex/instance/create/create_test.go b/internal/cmd/postgresflex/instance/create/create_test.go index 6adbfb09b..5e7f58cd8 100644 --- a/internal/cmd/postgresflex/instance/create/create_test.go +++ b/internal/cmd/postgresflex/instance/create/create_test.go @@ -5,13 +5,17 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} @@ -123,6 +127,7 @@ func fixturePayload(mods ...func(payload *postgresflex.CreateInstancePayload)) p func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -249,56 +254,9 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + aclFlag: tt.aclValues, + }, tt.isValid) }) } } @@ -547,7 +505,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.instanceId, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/postgresflex/instance/delete/delete.go b/internal/cmd/postgresflex/instance/delete/delete.go index 4c9077a6a..3a8aa4cfe 100644 --- a/internal/cmd/postgresflex/instance/delete/delete.go +++ b/internal/cmd/postgresflex/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +34,7 @@ type inputModel struct { ForceDelete bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a PostgreSQL Flex instance", @@ -52,29 +54,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } toDelete, toForceDelete, err := getNextOperations(ctx, model, apiClient) @@ -92,13 +92,13 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for PostgreSQL Flex instance deletion: %w", err) } - s.Stop() } } @@ -112,13 +112,13 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Forcing deletion of instance") - _, err = wait.ForceDeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Forcing deletion of instance", func() error { + _, err = wait.ForceDeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for PostgreSQL Flex instance force deletion: %w", err) } - s.Stop() } } @@ -132,7 +132,7 @@ func NewCmd(p *print.Printer) *cobra.Command { operationState = "Triggered forced deletion of" } } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -158,15 +158,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ForceDelete: flags.FlagToBoolValue(p, cmd, forceDeleteFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/postgresflex/instance/delete/delete_test.go b/internal/cmd/postgresflex/instance/delete/delete_test.go index 23f662a5d..fdbe26f4c 100644 --- a/internal/cmd/postgresflex/instance/delete/delete_test.go +++ b/internal/cmd/postgresflex/instance/delete/delete_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -170,54 +170,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/postgresflex/instance/describe/describe.go b/internal/cmd/postgresflex/instance/describe/describe.go index 2d59f5306..c97762a76 100644 --- a/internal/cmd/postgresflex/instance/describe/describe.go +++ b/internal/cmd/postgresflex/instance/describe/describe.go @@ -2,10 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,8 +18,6 @@ import ( postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "golang.org/x/text/cases" - "golang.org/x/text/language" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" @@ -31,7 +32,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of a PostgreSQL Flex instance", @@ -47,12 +48,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -64,7 +65,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read PostgreSQL Flex instance: %w", err) } - return outputResult(p, model.OutputFormat, resp.Item) + return outputResult(params.Printer, model.OutputFormat, resp.Item) }, } return cmd @@ -83,15 +84,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -104,24 +97,7 @@ func outputResult(p *print.Printer, outputFormat string, instance *postgresflex. if instance == nil { return fmt.Errorf("no response passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { acls := "" if instance.HasAcl() && instance.Acl.HasItems() { acls = utils.JoinStringPtr(instance.Acl.Items, ",") @@ -170,5 +146,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *postgresflex. } return nil - } + }) } diff --git a/internal/cmd/postgresflex/instance/describe/describe_test.go b/internal/cmd/postgresflex/instance/describe/describe_test.go index c6945faa1..fef025e6e 100644 --- a/internal/cmd/postgresflex/instance/describe/describe_test.go +++ b/internal/cmd/postgresflex/instance/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -137,54 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -259,7 +216,7 @@ func Test_outputResult(t *testing.T) { }, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/instance/instance.go b/internal/cmd/postgresflex/instance/instance.go index 4a005c13a..5beba3e48 100644 --- a/internal/cmd/postgresflex/instance/instance.go +++ b/internal/cmd/postgresflex/instance/instance.go @@ -8,13 +8,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for PostgreSQL Flex instances", @@ -22,15 +22,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(clone.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(clone.NewCmd(params)) } diff --git a/internal/cmd/postgresflex/instance/list/list.go b/internal/cmd/postgresflex/instance/list/list.go index 040a9de6b..9f1b0f690 100644 --- a/internal/cmd/postgresflex/instance/list/list.go +++ b/internal/cmd/postgresflex/instance/list/list.go @@ -2,11 +2,15 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,9 +21,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" - "golang.org/x/text/cases" - "golang.org/x/text/language" ) const ( @@ -31,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all PostgreSQL Flex instances", @@ -48,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 PostgreSQL Flex instances`, "$ stackit postgresflex instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,12 +69,12 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get PostgreSQL Flex instances: %w", err) } if resp.Items == nil || len(*resp.Items) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No instances found for project %q\n", projectLabel) + params.Printer.Info("No instances found for project %q\n", projectLabel) return nil } instances := *resp.Items @@ -83,7 +84,7 @@ func NewCmd(p *print.Printer) *cobra.Command { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, instances) }, } @@ -95,7 +96,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -114,15 +115,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -132,24 +125,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresfle } func outputResult(p *print.Printer, outputFormat string, instances []postgresflex.InstanceListInstance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instances, func() error { caser := cases.Title(language.English) table := tables.NewTable() table.SetHeader("ID", "NAME", "STATUS") @@ -167,5 +143,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []postgresfle } return nil - } + }) } diff --git a/internal/cmd/postgresflex/instance/list/list_test.go b/internal/cmd/postgresflex/instance/list/list_test.go index 09880f92c..0af825a2a 100644 --- a/internal/cmd/postgresflex/instance/list/list_test.go +++ b/internal/cmd/postgresflex/instance/list/list_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} @@ -59,6 +62,7 @@ func fixtureRequest(mods ...func(request *postgresflex.ApiListInstancesRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -113,48 +117,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -218,7 +181,7 @@ func Test_outputResult(t *testing.T) { }}, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/instance/update/update.go b/internal/cmd/postgresflex/instance/update/update.go index 66022fb1d..74a3a402d 100644 --- a/internal/cmd/postgresflex/instance/update/update.go +++ b/internal/cmd/postgresflex/instance/update/update.go @@ -2,11 +2,11 @@ package update import ( "context" - "encoding/json" "errors" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -54,7 +54,7 @@ type inputModel struct { Type *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates a PostgreSQL Flex instance", @@ -71,29 +71,27 @@ func NewCmd(p *print.Printer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q? (This may cause downtime)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q? (This may cause downtime)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -109,16 +107,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for PostgreSQL Flex instance update: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, instanceLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, instanceLabel, resp) }, } configureFlags(cmd) @@ -186,15 +184,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Type: instanceType, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -272,9 +262,9 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl payloadAcl = &postgresflex.ACL{Items: model.ACL} } - var payloadStorage *postgresflex.Storage + var payloadStorage *postgresflex.StorageUpdate if model.StorageClass != nil || model.StorageSize != nil { - payloadStorage = &postgresflex.Storage{ + payloadStorage = &postgresflex.StorageUpdate{ Class: model.StorageClass, Size: model.StorageSize, } @@ -312,29 +302,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, instanceLab return fmt.Errorf("no response passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgresFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgresFlex instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { operationState := "Updated" if async { operationState = "Triggered update of" } p.Info("%s instance %q\n", operationState, instanceLabel) return nil - } + }) } diff --git a/internal/cmd/postgresflex/instance/update/update_test.go b/internal/cmd/postgresflex/instance/update/update_test.go index 46b157bbe..8a3a0e61e 100644 --- a/internal/cmd/postgresflex/instance/update/update_test.go +++ b/internal/cmd/postgresflex/instance/update/update_test.go @@ -5,13 +5,16 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} @@ -282,7 +285,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -425,7 +428,7 @@ func TestBuildRequest(t *testing.T) { }, expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testRegion, testInstanceId). PartialUpdateInstancePayload(postgresflex.PartialUpdateInstancePayload{ - Storage: &postgresflex.Storage{ + Storage: &postgresflex.StorageUpdate{ Class: utils.Ptr("class"), }, }), @@ -453,7 +456,7 @@ func TestBuildRequest(t *testing.T) { }, expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testRegion, testInstanceId). PartialUpdateInstancePayload(postgresflex.PartialUpdateInstancePayload{ - Storage: &postgresflex.Storage{ + Storage: &postgresflex.StorageUpdate{ Class: utils.Ptr("class"), Size: utils.Ptr(int64(10)), }, @@ -620,7 +623,7 @@ func Test_outputResult(t *testing.T) { }, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/options/options.go b/internal/cmd/postgresflex/options/options.go index 8d53c2017..e569b331e 100644 --- a/internal/cmd/postgresflex/options/options.go +++ b/internal/cmd/postgresflex/options/options.go @@ -2,12 +2,15 @@ package options import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -15,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) const ( @@ -45,7 +47,7 @@ type flavorStorages struct { Storages *postgresflex.ListStoragesResponse `json:"storages"` } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "options", Short: "Lists PostgreSQL Flex options", @@ -62,21 +64,21 @@ func NewCmd(p *print.Printer) *cobra.Command { `List PostgreSQL Flex storage options for a given flavor. The flavor ID can be retrieved by running "$ stackit postgresflex options --flavors"`, "$ stackit postgresflex options --storages --flavor-id "), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } // Call API - err = buildAndExecuteRequest(ctx, p, model, apiClient) + err = buildAndExecuteRequest(ctx, params.Printer, model, apiClient) if err != nil { return fmt.Errorf("get PostgreSQL Flex options: %w", err) } @@ -95,8 +97,11 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(flavorIdFlag, "", `The flavor ID to show storages for. Only relevant when "--storages" is passed`) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } flavors := flags.FlagToBoolValue(p, cmd, flavorsFlag) versions := flags.FlagToBoolValue(p, cmd, versionsFlag) storages := flags.FlagToBoolValue(p, cmd, storagesFlag) @@ -123,15 +128,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { FlavorId: flags.FlagToStringPointer(p, cmd, flavorIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -187,45 +184,25 @@ func outputResult(p *print.Printer, model inputModel, flavors *postgresflex.List } } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(options, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex options: %w", err) + return p.OutputResult(model.OutputFormat, options, func() error { + content := []tables.Table{} + if model.Flavors && len(*options.Flavors) != 0 { + content = append(content, buildFlavorsTable(*options.Flavors)) } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(options, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if model.Versions && len(*options.Versions) != 0 { + content = append(content, buildVersionsTable(*options.Versions)) + } + if model.Storages && options.Storages.Storages != nil && len(*options.Storages.Storages.StorageClasses) > 0 { + content = append(content, buildStoragesTable(*options.Storages.Storages)) + } + + err := tables.DisplayTables(p, content) if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex options: %w", err) + return fmt.Errorf("display output: %w", err) } - p.Outputln(string(details)) return nil - default: - return outputResultAsTable(p, model, options) - } -} - -func outputResultAsTable(p *print.Printer, model inputModel, options *options) error { - content := []tables.Table{} - if model.Flavors && len(*options.Flavors) != 0 { - content = append(content, buildFlavorsTable(*options.Flavors)) - } - if model.Versions && len(*options.Versions) != 0 { - content = append(content, buildVersionsTable(*options.Versions)) - } - if model.Storages && options.Storages.Storages != nil && len(*options.Storages.Storages.StorageClasses) == 0 { - content = append(content, buildStoragesTable(*options.Storages.Storages)) - } - - err := tables.DisplayTables(p, content) - if err != nil { - return fmt.Errorf("display output: %w", err) - } - - return nil + }) } func buildFlavorsTable(flavors []postgresflex.Flavor) tables.Table { diff --git a/internal/cmd/postgresflex/options/options_test.go b/internal/cmd/postgresflex/options/options_test.go index 3854aeaca..9d56b28ea 100644 --- a/internal/cmd/postgresflex/options/options_test.go +++ b/internal/cmd/postgresflex/options/options_test.go @@ -5,16 +5,21 @@ import ( "fmt" "testing" - "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testProjectId = uuid.NewString() type postgresFlexClientMocked struct { listFlavorsFails bool @@ -62,10 +67,11 @@ func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - flavorsFlag: "true", - versionsFlag: "true", - storagesFlag: "true", - flavorIdFlag: "2.4", + globalflags.ProjectIdFlag: testProjectId, + flavorsFlag: "true", + versionsFlag: "true", + storagesFlag: "true", + flavorIdFlag: "2.4", } for _, mod := range mods { mod(flagValues) @@ -75,10 +81,13 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModelAllFalse(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, - Flavors: false, - Versions: false, - Storages: false, + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Flavors: false, + Versions: false, + Storages: false, } for _, mod := range mods { mod(model) @@ -88,11 +97,14 @@ func fixtureInputModelAllFalse(mods ...func(model *inputModel)) *inputModel { func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, - Flavors: true, - Versions: true, - Storages: true, - FlavorId: utils.Ptr("2.4"), + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Flavors: true, + Versions: true, + Storages: true, + FlavorId: utils.Ptr("2.4"), } for _, mod := range mods { mod(model) @@ -103,6 +115,7 @@ func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -165,46 +178,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -290,7 +264,7 @@ func TestBuildAndExecuteRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := &print.Printer{} - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) p.Cmd = cmd client := &postgresFlexClientMocked{ listFlavorsFails: tt.listFlavorsFails, @@ -360,7 +334,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.flavors, tt.args.versions, tt.args.storages); (err != nil) != tt.wantErr { diff --git a/internal/cmd/postgresflex/postgresflex.go b/internal/cmd/postgresflex/postgresflex.go index 7820cded2..536584f2f 100644 --- a/internal/cmd/postgresflex/postgresflex.go +++ b/internal/cmd/postgresflex/postgresflex.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/options" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/user" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "postgresflex", Aliases: []string{"postgresqlflex"}, @@ -21,13 +21,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(user.NewCmd(p)) - cmd.AddCommand(options.NewCmd(p)) - cmd.AddCommand(backup.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(user.NewCmd(params)) + cmd.AddCommand(options.NewCmd(params)) + cmd.AddCommand(backup.NewCmd(params)) } diff --git a/internal/cmd/postgresflex/user/create/create.go b/internal/cmd/postgresflex/user/create/create.go index ce2912e3a..97dc0c520 100644 --- a/internal/cmd/postgresflex/user/create/create.go +++ b/internal/cmd/postgresflex/user/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) const ( @@ -37,7 +38,7 @@ type inputModel struct { Roles *[]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a PostgreSQL Flex user", @@ -56,31 +57,29 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit postgresflex user create --instance-id xxx --username johndoe --role createdb"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -90,7 +89,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create PostgreSQL Flex user: %w", err) } - return outputResult(p, model.OutputFormat, instanceLabel, resp) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, resp) }, } @@ -109,7 +108,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -122,15 +121,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Roles: flags.FlagWithDefaultToStringSlicePointer(p, cmd, roleFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -147,24 +138,8 @@ func outputResult(p *print.Printer, outputFormat, instanceLabel string, resp *po if resp == nil { return fmt.Errorf("no response passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgresFlex user: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgresFlex user: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { if user := resp.Item; user != nil { p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, utils.PtrString(user.Id)) p.Outputf("Username: %s\n", utils.PtrString(user.Username)) @@ -176,5 +151,5 @@ func outputResult(p *print.Printer, outputFormat, instanceLabel string, resp *po } return nil - } + }) } diff --git a/internal/cmd/postgresflex/user/create/create_test.go b/internal/cmd/postgresflex/user/create/create_test.go index 6c0fe71ba..cc630472c 100644 --- a/internal/cmd/postgresflex/user/create/create_test.go +++ b/internal/cmd/postgresflex/user/create/create_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} @@ -69,6 +72,7 @@ func fixtureRequest(mods ...func(request *postgresflex.ApiCreateUserRequest)) po func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -148,48 +152,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -258,7 +221,7 @@ func Test_outputResult(t *testing.T) { }}, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/user/delete/delete.go b/internal/cmd/postgresflex/user/delete/delete.go index f9f166291..d3646805a 100644 --- a/internal/cmd/postgresflex/user/delete/delete.go +++ b/internal/cmd/postgresflex/user/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", userIdArg), Short: "Deletes a PostgreSQL Flex user", @@ -47,35 +49,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } userLabel, err := postgresflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId, model.UserId) if err != nil { - p.Debug(print.ErrorLevel, "get user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user name: %v", err) userLabel = model.UserId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -85,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete PostgreSQL Flex user: %w", err) } - p.Info("Deleted user %q of instance %q\n", userLabel, instanceLabel) + params.Printer.Info("Deleted user %q of instance %q\n", userLabel, instanceLabel) return nil }, } @@ -114,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/postgresflex/user/delete/delete_test.go b/internal/cmd/postgresflex/user/delete/delete_test.go index cd6dfa986..a3ff66105 100644 --- a/internal/cmd/postgresflex/user/delete/delete_test.go +++ b/internal/cmd/postgresflex/user/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -159,54 +159,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/postgresflex/user/describe/describe.go b/internal/cmd/postgresflex/user/describe/describe.go index cc02a616b..4bf92198a 100644 --- a/internal/cmd/postgresflex/user/describe/describe.go +++ b/internal/cmd/postgresflex/user/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", userIdArg), Short: "Shows details of a PostgreSQL Flex user", @@ -52,13 +53,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -70,7 +71,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get MongoDB Flex user: %w", err) } - return outputResult(p, model.OutputFormat, *resp.Item) + return outputResult(params.Printer, model.OutputFormat, *resp.Item) }, } @@ -99,15 +100,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -117,24 +110,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresfle } func outputResult(p *print.Printer, outputFormat string, user postgresflex.UserResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex user: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, user, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(user.Id)) table.AddSeparator() @@ -152,5 +128,5 @@ func outputResult(p *print.Printer, outputFormat string, user postgresflex.UserR } return nil - } + }) } diff --git a/internal/cmd/postgresflex/user/describe/describe_test.go b/internal/cmd/postgresflex/user/describe/describe_test.go index 4ff8819b9..214df4ff3 100644 --- a/internal/cmd/postgresflex/user/describe/describe_test.go +++ b/internal/cmd/postgresflex/user/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -158,54 +162,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -259,7 +216,7 @@ func Test_outputResult(t *testing.T) { }}, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/user/list/list.go b/internal/cmd/postgresflex/user/list/list.go index 0b530674d..d0c9dbd36 100644 --- a/internal/cmd/postgresflex/user/list/list.go +++ b/internal/cmd/postgresflex/user/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all PostgreSQL Flex users of an instance", @@ -49,15 +50,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit postgresflex user list --instance-id xxx --limit 10"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -71,10 +72,10 @@ func NewCmd(p *print.Printer) *cobra.Command { if resp.Items == nil || len(*resp.Items) == 0 { instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, *model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = *model.InstanceId } - p.Info("No users found for instance %q\n", instanceLabel) + params.Printer.Info("No users found for instance %q\n", instanceLabel) return nil } users := *resp.Items @@ -84,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { users = users[:*model.Limit] } - return outputResult(p, model.OutputFormat, users) + return outputResult(params.Printer, model.OutputFormat, users) }, } @@ -100,7 +101,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -120,15 +121,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -138,24 +131,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresfle } func outputResult(p *print.Printer, outputFormat string, users []postgresflex.ListUsersResponseItem) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(users, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex user list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(users, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex user list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, users, func() error { table := tables.NewTable() table.SetHeader("ID", "USERNAME") for i := range users { @@ -171,5 +147,5 @@ func outputResult(p *print.Printer, outputFormat string, users []postgresflex.Li } return nil - } + }) } diff --git a/internal/cmd/postgresflex/user/list/list_test.go b/internal/cmd/postgresflex/user/list/list_test.go index ed82bbb26..d84fd6706 100644 --- a/internal/cmd/postgresflex/user/list/list_test.go +++ b/internal/cmd/postgresflex/user/list/list_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) type testCtxKey struct{} @@ -62,6 +65,7 @@ func fixtureRequest(mods ...func(request *postgresflex.ApiListUsersRequest)) pos func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -130,48 +134,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -222,7 +185,7 @@ func Test_outputResult(t *testing.T) { }}}, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/user/reset-password/reset_password.go b/internal/cmd/postgresflex/user/reset-password/reset_password.go index d2d240585..19e34dd2e 100644 --- a/internal/cmd/postgresflex/user/reset-password/reset_password.go +++ b/internal/cmd/postgresflex/user/reset-password/reset_password.go @@ -2,11 +2,13 @@ package resetpassword import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client" postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("reset-password %s", userIdArg), Short: "Resets the password of a PostgreSQL Flex user", @@ -48,35 +49,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } userLabel, err := postgresflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId, model.UserId) if err != nil { - p.Debug(print.ErrorLevel, "get user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user name: %v", err) userLabel = model.UserId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to reset the password of user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to reset the password of user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -86,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("reset PostgreSQL Flex user password: %w", err) } - return outputResult(p, model.OutputFormat, userLabel, instanceLabel, user) + return outputResult(params.Printer, model.OutputFormat, userLabel, instanceLabel, user) }, } @@ -115,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -136,24 +127,7 @@ func outputResult(p *print.Printer, outputFormat, userLabel, instanceLabel strin if user == nil { return fmt.Errorf("no response passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgresFlex user: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgresFlex user: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, user, func() error { p.Outputf("Reset password for user %q of instance %q\n\n", userLabel, instanceLabel) if item := user.Item; item != nil { p.Outputf("Username: %s\n", utils.PtrString(item.Username)) @@ -161,5 +135,5 @@ func outputResult(p *print.Printer, outputFormat, userLabel, instanceLabel strin p.Outputf("New URI: %s\n", utils.PtrString(item.Uri)) } return nil - } + }) } diff --git a/internal/cmd/postgresflex/user/reset-password/reset_password_test.go b/internal/cmd/postgresflex/user/reset-password/reset_password_test.go index 8cde43c41..9b13c8b42 100644 --- a/internal/cmd/postgresflex/user/reset-password/reset_password_test.go +++ b/internal/cmd/postgresflex/user/reset-password/reset_password_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -158,54 +162,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -260,7 +217,7 @@ func Test_outputResult(t *testing.T) { }}, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/postgresflex/user/update/update.go b/internal/cmd/postgresflex/user/update/update.go index 47872ecc5..fa8d8e445 100644 --- a/internal/cmd/postgresflex/user/update/update.go +++ b/internal/cmd/postgresflex/user/update/update.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +34,7 @@ type inputModel struct { Roles *[]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", userIdArg), Short: "Updates a PostgreSQL Flex user", @@ -45,35 +47,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, nil), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } userLabel, err := postgresflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId, model.UserId) if err != nil { - p.Debug(print.ErrorLevel, "get user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user name: %v", err) userLabel = model.UserId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update user %q of instance %q?", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update user %q of instance %q?", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -83,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update PostgreSQL Flex user: %w", err) } - p.Info("Updated user %q of instance %q\n", userLabel, instanceLabel) + params.Printer.Info("Updated user %q of instance %q\n", userLabel, instanceLabel) return nil }, } @@ -122,15 +122,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Roles: roles, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/postgresflex/user/update/update_test.go b/internal/cmd/postgresflex/user/update/update_test.go index fb0fe9cd3..c20ded9b7 100644 --- a/internal/cmd/postgresflex/user/update/update_test.go +++ b/internal/cmd/postgresflex/user/update/update_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -168,54 +168,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/postgresflex/user/user.go b/internal/cmd/postgresflex/user/user.go index 018244197..ce566f5b7 100644 --- a/internal/cmd/postgresflex/user/user.go +++ b/internal/cmd/postgresflex/user/user.go @@ -8,13 +8,13 @@ import ( resetpassword "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/user/reset-password" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/user/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "user", Short: "Provides functionality for PostgreSQL Flex users", @@ -22,15 +22,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(resetpassword.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(resetpassword.NewCmd(params)) } diff --git a/internal/cmd/project/create/create.go b/internal/cmd/project/create/create.go index 99f62fadd..c18e083fc 100644 --- a/internal/cmd/project/create/create.go +++ b/internal/cmd/project/create/create.go @@ -2,11 +2,11 @@ package create import ( "context" - "encoding/json" "fmt" "regexp" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -41,7 +41,7 @@ type inputModel struct { NetworkAreaId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a STACKIT project", @@ -64,25 +64,23 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a STACKIT project with a network area`, "$ stackit project create --parent-id xxxx --name my-project --network-area-id yyyy"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) 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 %q?", *model.ParentId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a project under the parent with ID %q?", *model.ParentId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -95,7 +93,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create project: %w", err) } - return outputResult(p, *model, resp) + return outputResult(params.Printer, *model, resp) }, } configureFlags(cmd) @@ -112,7 +110,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) labels := flags.FlagToStringToStringPointer(p, cmd, labelFlag) @@ -144,15 +142,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -219,25 +209,8 @@ func outputResult(p *print.Printer, model inputModel, resp *resourcemanager.Proj if model.GlobalFlagModel == nil { return fmt.Errorf("globalflags are empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal project: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal project: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { p.Outputf("Created project under the parent with ID %q. Project ID: %s\n", utils.PtrString(model.ParentId), utils.PtrString(resp.ProjectId)) return nil - } + }) } diff --git a/internal/cmd/project/create/create_test.go b/internal/cmd/project/create/create_test.go index 8c977d3c6..d96d3b475 100644 --- a/internal/cmd/project/create/create_test.go +++ b/internal/cmd/project/create/create_test.go @@ -4,15 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" - "github.com/zalando/go-keyring" ) type testCtxKey struct{} @@ -85,6 +89,7 @@ func fixtureRequest(mods ...func(request *resourcemanager.ApiCreateProjectReques func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string labelValues []string isValid bool @@ -176,56 +181,9 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + labelFlag: tt.labelValues, + }, tt.isValid) }) } } @@ -374,7 +332,7 @@ func Test_outputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/project/delete/delete.go b/internal/cmd/project/delete/delete.go index 9b3178bce..14de5b63f 100644 --- a/internal/cmd/project/delete/delete.go +++ b/internal/cmd/project/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -20,7 +22,7 @@ type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "delete", Short: "Deletes a STACKIT project", @@ -34,31 +36,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Delete a STACKIT project by explicitly providing the project ID`, "$ stackit project delete --project-id xxx"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete the project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete the project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -68,8 +68,8 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete project: %w", err) } - p.Info("Deleted project %q\n", projectLabel) - p.Warn("%s", fmt.Sprintf("%s\n%s\n", + params.Printer.Info("Deleted project %q\n", projectLabel) + params.Printer.Warn("%s", fmt.Sprintf("%s\n%s\n", "If this was your default project, consider configuring a new project ID by running:", " $ stackit config set --project-id ", )) @@ -79,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -89,15 +89,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/project/delete/delete_test.go b/internal/cmd/project/delete/delete_test.go index b53ede53d..f14e83869 100644 --- a/internal/cmd/project/delete/delete_test.go +++ b/internal/cmd/project/delete/delete_test.go @@ -5,12 +5,11 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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" ) @@ -55,6 +54,7 @@ func fixtureRequest(mods ...func(request *resourcemanager.ApiDeleteProjectReques func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -74,46 +74,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/project/describe/describe.go b/internal/cmd/project/describe/describe.go index 26162f859..52dc280b3 100644 --- a/internal/cmd/project/describe/describe.go +++ b/internal/cmd/project/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -31,7 +31,7 @@ type inputModel struct { IncludeParents bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "describe", Short: "Shows details of a STACKIT project", @@ -50,13 +50,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,7 +68,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read project details: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -87,7 +87,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" && projectId == "" { - return nil, fmt.Errorf("Project ID needs to be provided either as an argument or as a flag") + return nil, fmt.Errorf("project ID needs to be provided either as an argument or as a flag") } if projectId == "" { @@ -100,15 +100,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu IncludeParents: flags.FlagToBoolValue(p, cmd, includeParentsFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -122,24 +114,8 @@ func outputResult(p *print.Printer, outputFormat string, project *resourcemanage if project == nil { return fmt.Errorf("response not set") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(project, "", " ") - if err != nil { - return fmt.Errorf("marshal project details: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(project, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal project details: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, project, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(project.ProjectId)) table.AddSeparator() @@ -158,5 +134,5 @@ func outputResult(p *print.Printer, outputFormat string, project *resourcemanage } return nil - } + }) } diff --git a/internal/cmd/project/describe/describe_test.go b/internal/cmd/project/describe/describe_test.go index 3c2e5f658..218f36671 100644 --- a/internal/cmd/project/describe/describe_test.go +++ b/internal/cmd/project/describe/describe_test.go @@ -5,13 +5,17 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -136,54 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -238,7 +195,7 @@ func Test_outputResult(t *testing.T) { }, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.project); (err != nil) != tt.wantErr { diff --git a/internal/cmd/project/list/list.go b/internal/cmd/project/list/list.go index 4459558ad..497aefb2e 100644 --- a/internal/cmd/project/list/list.go +++ b/internal/cmd/project/list/list.go @@ -2,12 +2,14 @@ package list import ( "context" - "encoding/json" "fmt" "time" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -18,7 +20,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/resourcemanager/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" ) const ( @@ -43,7 +44,7 @@ type inputModel struct { PageSize int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists STACKIT projects", @@ -63,15 +64,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all STACKIT projects that a certain user is a member of`, "$ stackit project list --member example@email.com"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -82,11 +83,11 @@ func NewCmd(p *print.Printer) *cobra.Command { return err } if len(projects) == 0 { - p.Info("No projects found matching the criteria\n") + params.Printer.Info("No projects found matching the criteria\n") return nil } - return outputResult(p, model.OutputFormat, projects) + return outputResult(params.Printer, model.OutputFormat, projects) }, } configureFlags(cmd) @@ -102,7 +103,7 @@ func configureFlags(cmd *cobra.Command) { 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(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) creationTimeAfter, err := flags.FlagToDateTimePointer(p, cmd, creationTimeAfterFlag, creationTimeAfterFormat) @@ -139,15 +140,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { PageSize: pageSize, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -220,24 +213,7 @@ func fetchProjects(ctx context.Context, model *inputModel, apiClient resourceMan } func outputResult(p *print.Printer, outputFormat string, projects []resourcemanager.Project) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(projects, "", " ") - if err != nil { - return fmt.Errorf("marshal projects list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(projects, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal projects list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, projects, func() error { table := tables.NewTable() table.SetHeader("ID", "NAME", "STATE", "PARENT ID") for i := range projects { @@ -261,5 +237,5 @@ func outputResult(p *print.Printer, outputFormat string, projects []resourcemana } return nil - } + }) } diff --git a/internal/cmd/project/list/list_test.go b/internal/cmd/project/list/list_test.go index 47db45bac..7e26d6f8c 100644 --- a/internal/cmd/project/list/list_test.go +++ b/internal/cmd/project/list/list_test.go @@ -9,16 +9,21 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "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" + "github.com/zalando/go-keyring" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" - "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" - "github.com/zalando/go-keyring" ) type testCtxKey struct{} @@ -66,7 +71,7 @@ func fixtureRequest(mods ...func(request *resourcemanager.ApiListProjectsRequest testCreationTimeAfter, err := time.Parse(creationTimeAfterFormat, testCreationTimeAfter) if err != nil { - return resourcemanager.ApiListProjectsRequest{} + return resourcemanager.ListProjectsRequest{} } request = request.CreationTimeStart(testCreationTimeAfter) request = request.Member("member") @@ -80,6 +85,7 @@ func fixtureRequest(mods ...func(request *resourcemanager.ApiListProjectsRequest func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string projectIdLikevalues *[]string isValid bool @@ -193,66 +199,9 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + projectIdLikeFlag: utils.GetSliceFromPointer(tt.projectIdLikevalues), + }, tt.isValid) }) } } @@ -539,7 +488,7 @@ func Test_outputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/cmd/project/member/add/add.go b/internal/cmd/project/member/add/add.go index 23436f039..0901fba52 100644 --- a/internal/cmd/project/member/add/add.go +++ b/internal/cmd/project/member/add/add.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +35,7 @@ type inputModel struct { Role *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("add %s", subjectArg), Short: "Adds a member to a project", @@ -52,29 +54,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to add the role %q to %s on project %q?", *model.Role, model.Subject, projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to add the role %q to %s on project %q?", *model.Role, model.Subject, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,7 +84,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("add member: %w", err) } - p.Info("Added the role %q to %s on project %q\n", utils.PtrString(model.Role), model.Subject, projectLabel) + params.Printer.Info("Added the role %q to %s on project %q\n", utils.PtrString(model.Role), model.Subject, projectLabel) return nil }, } @@ -113,20 +113,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Role: flags.FlagToStringPointer(p, cmd, roleFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *authorization.APIClient) authorization.ApiAddMembersRequest { - req := apiClient.AddMembers(ctx, model.GlobalFlagModel.ProjectId) + req := apiClient.AddMembers(ctx, model.ProjectId) req = req.AddMembersPayload(authorization.AddMembersPayload{ Members: utils.Ptr([]authorization.Member{ { diff --git a/internal/cmd/project/member/add/add_test.go b/internal/cmd/project/member/add/add_test.go index e9fc2b4d6..fa8cb5f04 100644 --- a/internal/cmd/project/member/add/add_test.go +++ b/internal/cmd/project/member/add/add_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -123,54 +123,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/project/member/list/list.go b/internal/cmd/project/member/list/list.go index 861423dc5..97b3003ef 100644 --- a/internal/cmd/project/member/list/list.go +++ b/internal/cmd/project/member/list/list.go @@ -2,12 +2,14 @@ package list import ( "context" - "encoding/json" "fmt" "sort" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -18,7 +20,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/authorization/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/authorization" ) const ( @@ -37,7 +38,7 @@ type inputModel struct { SortBy string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists members of a project", @@ -54,15 +55,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 members of a project`, "$ stackit project member list --project-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -75,12 +76,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } members := *resp.Members if len(members) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No members found for project %q\n", projectLabel) + params.Printer.Info("No members found for project %q\n", projectLabel) return nil } @@ -89,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command { members = members[:*model.Limit] } - return outputResult(p, *model, members) + return outputResult(params.Printer, *model, members) }, } configureFlags(cmd) @@ -104,7 +105,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.EnumFlag(false, "subject", sortByFlagOptions...), sortByFlag, fmt.Sprintf("Sort entries by a specific field, one of %q", sortByFlagOptions)) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -125,20 +126,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { SortBy: flags.FlagWithDefaultToStringValue(p, cmd, sortByFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *authorization.APIClient) authorization.ApiListMembersRequest { - req := apiClient.ListMembers(ctx, projectResourceType, model.GlobalFlagModel.ProjectId) + req := apiClient.ListMembers(ctx, projectResourceType, model.ProjectId) if model.Subject != nil { req = req.Subject(*model.Subject) } @@ -161,25 +154,7 @@ func outputResult(p *print.Printer, model inputModel, members []authorization.Me } sort.SliceStable(members, sortFn) - switch model.OutputFormat { - case print.JSONOutputFormat: - // Show details - details, err := json.MarshalIndent(members, "", " ") - if err != nil { - return fmt.Errorf("marshal members: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(members, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal members: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, members, func() error { table := tables.NewTable() table.SetHeader("SUBJECT", "ROLE") for i := range members { @@ -191,9 +166,10 @@ func outputResult(p *print.Printer, model inputModel, members []authorization.Me table.AddRow(utils.PtrString(m.Subject), utils.PtrString(m.Role)) } - if model.SortBy == "subject" { + switch model.SortBy { + case "subject": table.EnableAutoMergeOnColumns(1) - } else if model.SortBy == "role" { + case "role": table.EnableAutoMergeOnColumns(2) } @@ -203,5 +179,5 @@ func outputResult(p *print.Printer, model inputModel, members []authorization.Me } return nil - } + }) } diff --git a/internal/cmd/project/member/list/list_test.go b/internal/cmd/project/member/list/list_test.go index 4aecdee12..942a3096b 100644 --- a/internal/cmd/project/member/list/list_test.go +++ b/internal/cmd/project/member/list/list_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/authorization" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/authorization" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -59,6 +62,7 @@ func fixtureRequest(mods ...func(request *authorization.ApiListMembersRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -128,48 +132,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -240,7 +203,7 @@ func Test_outputResult(t *testing.T) { false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.members); (err != nil) != tt.wantErr { diff --git a/internal/cmd/project/member/member.go b/internal/cmd/project/member/member.go index 658f6ff7c..4b247e877 100644 --- a/internal/cmd/project/member/member.go +++ b/internal/cmd/project/member/member.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/project/member/list" "github.com/stackitcloud/stackit-cli/internal/cmd/project/member/remove" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "member", Short: "Manages project members", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(add.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(remove.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(add.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(remove.NewCmd(params)) } diff --git a/internal/cmd/project/member/remove/remove.go b/internal/cmd/project/member/remove/remove.go index 56004a03b..70a7c46d6 100644 --- a/internal/cmd/project/member/remove/remove.go +++ b/internal/cmd/project/member/remove/remove.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -35,7 +37,7 @@ type inputModel struct { Force bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("remove %s", subjectArg), Short: "Removes a member from a project", @@ -55,32 +57,30 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to remove the role %q from %s on project %q?", *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 = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to remove the role %q from %s on project %q?", *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 = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -90,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("remove member: %w", err) } - p.Info("Removed the role %q from %s on project %q\n", utils.PtrString(model.Role), model.Subject, projectLabel) + params.Printer.Info("Removed the role %q from %s on project %q\n", utils.PtrString(model.Role), model.Subject, projectLabel) return nil }, } @@ -121,20 +121,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Force: flags.FlagToBoolValue(p, cmd, forceFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *authorization.APIClient) authorization.ApiRemoveMembersRequest { - req := apiClient.RemoveMembers(ctx, model.GlobalFlagModel.ProjectId) + req := apiClient.RemoveMembers(ctx, model.ProjectId) payload := authorization.RemoveMembersPayload{ Members: utils.Ptr([]authorization.Member{ { diff --git a/internal/cmd/project/member/remove/remove_test.go b/internal/cmd/project/member/remove/remove_test.go index 626efe0d6..d0fc6d8f0 100644 --- a/internal/cmd/project/member/remove/remove_test.go +++ b/internal/cmd/project/member/remove/remove_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -136,54 +136,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/project/project.go b/internal/cmd/project/project.go index ded8f8444..c1a04db9a 100644 --- a/internal/cmd/project/project.go +++ b/internal/cmd/project/project.go @@ -3,6 +3,8 @@ package project import ( "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/cmd/project/create" "github.com/stackitcloud/stackit-cli/internal/cmd/project/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/project/describe" @@ -11,13 +13,12 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/project/role" "github.com/stackitcloud/stackit-cli/internal/cmd/project/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "project", Short: "Manages projects", @@ -28,16 +29,16 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(member.NewCmd(p)) - cmd.AddCommand(role.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(member.NewCmd(params)) + cmd.AddCommand(role.NewCmd(params)) } diff --git a/internal/cmd/project/role/list/list.go b/internal/cmd/project/role/list/list.go index 4b98bf977..292cad0d2 100644 --- a/internal/cmd/project/role/list/list.go +++ b/internal/cmd/project/role/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/authorization" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/authorization/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/authorization" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists roles and permissions of a project", @@ -49,15 +50,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 roles and permissions of a project`, "$ stackit project role list --project-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -70,12 +71,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } roles := *resp.Roles if len(roles) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No roles found for project %q\n", projectLabel) + params.Printer.Info("No roles found for project %q\n", projectLabel) return nil } @@ -84,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { roles = roles[:*model.Limit] } - return outputRolesResult(p, model.OutputFormat, roles) + return outputRolesResult(params.Printer, model.OutputFormat, roles) }, } configureFlags(cmd) @@ -95,7 +96,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -114,42 +115,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *authorization.APIClient) authorization.ApiListRolesRequest { - return apiClient.ListRoles(ctx, projectResourceType, model.GlobalFlagModel.ProjectId) + return apiClient.ListRoles(ctx, projectResourceType, model.ProjectId) } func outputRolesResult(p *print.Printer, outputFormat string, roles []authorization.Role) error { - switch outputFormat { - case print.JSONOutputFormat: - // Show details - details, err := json.MarshalIndent(roles, "", " ") - if err != nil { - return fmt.Errorf("marshal roles: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(roles, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal roles: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, roles, func() error { table := tables.NewTable() table.SetHeader("ROLE NAME", "ROLE DESCRIPTION", "PERMISSION NAME", "PERMISSION DESCRIPTION") for i := range roles { @@ -172,5 +147,5 @@ func outputRolesResult(p *print.Printer, outputFormat string, roles []authorizat } return nil - } + }) } diff --git a/internal/cmd/project/role/list/list_test.go b/internal/cmd/project/role/list/list_test.go index 4e8744528..0c70eed79 100644 --- a/internal/cmd/project/role/list/list_test.go +++ b/internal/cmd/project/role/list/list_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/authorization" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/authorization" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -58,6 +61,7 @@ func fixtureRequest(mods ...func(request *authorization.ApiListRolesRequest)) au func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -98,48 +102,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -196,7 +159,7 @@ func Test_outputRolesResult(t *testing.T) { }}, false}, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputRolesResult(p, tt.args.outputFormat, tt.args.roles); (err != nil) != tt.wantErr { diff --git a/internal/cmd/project/role/role.go b/internal/cmd/project/role/role.go index b460e54b7..1c4c119a9 100644 --- a/internal/cmd/project/role/role.go +++ b/internal/cmd/project/role/role.go @@ -3,13 +3,13 @@ package role import ( "github.com/stackitcloud/stackit-cli/internal/cmd/project/role/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "role", Short: "Manages project roles", @@ -17,10 +17,10 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/project/update/update.go b/internal/cmd/project/update/update.go index ea91ccdae..a0ee9ae4f 100644 --- a/internal/cmd/project/update/update.go +++ b/internal/cmd/project/update/update.go @@ -5,6 +5,8 @@ import ( "fmt" "regexp" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -35,7 +37,7 @@ type inputModel struct { Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "update", Short: "Updates a STACKIT project", @@ -53,31 +55,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -87,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update project: %w", err) } - p.Info("Updated project %q\n", projectLabel) + params.Printer.Info("Updated project %q\n", projectLabel) return nil }, } @@ -101,7 +101,7 @@ func configureFlags(cmd *cobra.Command) { 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(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -142,15 +142,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Labels: labels, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/project/update/update_test.go b/internal/cmd/project/update/update_test.go index 836110963..a2f70d560 100644 --- a/internal/cmd/project/update/update_test.go +++ b/internal/cmd/project/update/update_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -65,6 +65,7 @@ func fixtureRequest(mods ...func(request *resourcemanager.ApiPartialUpdateProjec func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string labelValues []string isValid bool @@ -130,56 +131,9 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + labelFlag: tt.labelValues, + }, tt.isValid) }) } } diff --git a/internal/cmd/public-ip/associate/associate.go b/internal/cmd/public-ip/associate/associate.go index 9ce91a954..9b86d2f61 100644 --- a/internal/cmd/public-ip/associate/associate.go +++ b/internal/cmd/public-ip/associate/associate.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -30,7 +33,7 @@ type inputModel struct { AssociatedResourceId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("associate %s", publicIpIdArg), Short: "Associates a Public IP with a network interface or a virtual IP", @@ -44,31 +47,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.PublicIpId) + publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId) if err != nil { - p.Debug(print.ErrorLevel, "get public IP: %v", err) + params.Printer.Debug(print.ErrorLevel, "get public IP: %v", err) publicIpLabel = model.PublicIpId } else if publicIpLabel == "" { publicIpLabel = model.PublicIpId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to associate public IP %q with resource %v?", publicIpLabel, *model.AssociatedResourceId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to associate public IP %q with resource %v?", publicIpLabel, *model.AssociatedResourceId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("associate public IP: %w", err) } - p.Outputf("Associated public IP %q with resource %v.\n", publicIpLabel, utils.PtrString(resp.GetNetworkInterface())) + params.Printer.Outputf("Associated public IP %q with resource %v.\n", publicIpLabel, utils.PtrString(resp.GetNetworkInterface())) return nil }, } @@ -107,20 +108,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu PublicIpId: publicIpId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdatePublicIPRequest { - req := apiClient.UpdatePublicIP(ctx, model.ProjectId, model.PublicIpId) + req := apiClient.UpdatePublicIP(ctx, model.ProjectId, model.Region, model.PublicIpId) payload := iaas.UpdatePublicIPPayload{ NetworkInterface: iaas.NewNullableString(model.AssociatedResourceId), diff --git a/internal/cmd/public-ip/associate/associate_test.go b/internal/cmd/public-ip/associate/associate_test.go index 99b51c3e6..113ddc50c 100644 --- a/internal/cmd/public-ip/associate/associate_test.go +++ b/internal/cmd/public-ip/associate/associate_test.go @@ -4,16 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,7 +41,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + associatedResourceIdFlag: testAssociatedResourceId, } for _, mod := range mods { @@ -50,6 +57,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, PublicIpId: testPublicIpId, AssociatedResourceId: utils.Ptr(testAssociatedResourceId), @@ -61,7 +69,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUpdatePublicIPRequest)) iaas.ApiUpdatePublicIPRequest { - request := testClient.UpdatePublicIP(testCtx, testProjectId, testPublicIpId) + request := testClient.UpdatePublicIP(testCtx, testProjectId, testRegion, testPublicIpId) request = request.UpdatePublicIPPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -104,7 +112,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +120,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +128,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -165,7 +173,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/public-ip/create/create.go b/internal/cmd/public-ip/create/create.go index 0ba5c6a84..9da469d1e 100644 --- a/internal/cmd/public-ip/create/create.go +++ b/internal/cmd/public-ip/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -30,7 +31,7 @@ type inputModel struct { Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Public IP", @@ -50,33 +51,31 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit public-ip create --associated-resource-id xxx --labels key=value,foo=bar`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a public IP for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a public IP for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -86,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create public IP: %w", err) } - return outputResult(p, model.OutputFormat, projectLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, projectLabel, *resp) }, } configureFlags(cmd) @@ -98,7 +97,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a public IP. E.g. '--labels key1=value1,key2=value2,...'") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -110,58 +109,24 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreatePublicIPRequest { - req := apiClient.CreatePublicIP(ctx, model.ProjectId) - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } + req := apiClient.CreatePublicIP(ctx, model.ProjectId, model.Region) payload := iaas.CreatePublicIPPayload{ NetworkInterface: iaas.NewNullableString(model.AssociatedResourceId), - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } return req.CreatePublicIPPayload(payload) } func outputResult(p *print.Printer, outputFormat, projectLabel string, publicIp iaas.PublicIp) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(publicIp, "", " ") - if err != nil { - return fmt.Errorf("marshal public IP: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(publicIp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal public IP: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, publicIp, func() error { p.Outputf("Created public IP for project %q.\nPublic IP ID: %s\n", projectLabel, utils.PtrString(publicIp.Id)) return nil - } + }) } diff --git a/internal/cmd/public-ip/create/create_test.go b/internal/cmd/public-ip/create/create_test.go index b204d561f..a4d0a4b23 100644 --- a/internal/cmd/public-ip/create/create_test.go +++ b/internal/cmd/public-ip/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -26,7 +31,9 @@ var testAssociatedResourceId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + associatedResourceIdFlag: testAssociatedResourceId, labelFlag: "key=value", } @@ -41,6 +48,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, AssociatedResourceId: utils.Ptr(testAssociatedResourceId), Labels: utils.Ptr(map[string]string{ @@ -54,7 +62,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiCreatePublicIPRequest)) iaas.ApiCreatePublicIPRequest { - request := testClient.CreatePublicIP(testCtx, testProjectId) + request := testClient.CreatePublicIP(testCtx, testProjectId, testRegion) request = request.CreatePublicIPPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -78,6 +86,7 @@ func fixturePayload(mods ...func(payload *iaas.CreatePublicIPPayload)) iaas.Crea func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -119,21 +128,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -141,46 +150,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -232,7 +202,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.publicIp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/public-ip/delete/delete.go b/internal/cmd/public-ip/delete/delete.go index 57a427dd6..8663ab334 100644 --- a/internal/cmd/public-ip/delete/delete.go +++ b/internal/cmd/public-ip/delete/delete.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -25,7 +28,7 @@ type inputModel struct { PublicIpId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", publicIpIdArg), Short: "Deletes a Public IP", @@ -42,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.PublicIpId) + publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId) if err != nil { - p.Debug(print.ErrorLevel, "get public IP: %v", err) + params.Printer.Debug(print.ErrorLevel, "get public IP: %v", err) publicIpLabel = model.PublicIpId } else if publicIpLabel == "" { publicIpLabel = model.PublicIpId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete public IP %q? (This cannot be undone)", publicIpLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete public IP %q? (This cannot be undone)", publicIpLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -76,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete public IP: %w", err) } - p.Info("Deleted public IP %q\n", publicIpLabel) + params.Printer.Info("Deleted public IP %q\n", publicIpLabel) return nil }, } @@ -96,18 +97,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu PublicIpId: publicIpId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeletePublicIPRequest { - return apiClient.DeletePublicIP(ctx, model.ProjectId, model.PublicIpId) + return apiClient.DeletePublicIP(ctx, model.ProjectId, model.Region, model.PublicIpId) } diff --git a/internal/cmd/public-ip/delete/delete_test.go b/internal/cmd/public-ip/delete/delete_test.go index 1acd8b016..25290233e 100644 --- a/internal/cmd/public-ip/delete/delete_test.go +++ b/internal/cmd/public-ip/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, PublicIpId: testPublicIpId, } @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeletePublicIPRequest)) iaas.ApiDeletePublicIPRequest { - request := testClient.DeletePublicIP(testCtx, testProjectId, testPublicIpId) + request := testClient.DeletePublicIP(testCtx, testProjectId, testRegion, testPublicIpId) for _, mod := range mods { mod(&request) } @@ -101,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/public-ip/describe/describe.go b/internal/cmd/public-ip/describe/describe.go index 820d9ff61..a4334b421 100644 --- a/internal/cmd/public-ip/describe/describe.go +++ b/internal/cmd/public-ip/describe/describe.go @@ -2,11 +2,12 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -16,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ type inputModel struct { PublicIpId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", publicIpIdArg), Short: "Shows details of a Public IP", @@ -48,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read public IP: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } return cmd @@ -85,41 +85,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu PublicIpId: publicIpId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetPublicIPRequest { - return apiClient.GetPublicIP(ctx, model.ProjectId, model.PublicIpId) + return apiClient.GetPublicIP(ctx, model.ProjectId, model.Region, model.PublicIpId) } func outputResult(p *print.Printer, outputFormat string, publicIp iaas.PublicIp) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(publicIp, "", " ") - if err != nil { - return fmt.Errorf("marshal public IP: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(publicIp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal public IP: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, publicIp, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(publicIp.Id)) table.AddSeparator() @@ -145,5 +120,5 @@ func outputResult(p *print.Printer, outputFormat string, publicIp iaas.PublicIp) return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/public-ip/describe/describe_test.go b/internal/cmd/public-ip/describe/describe_test.go index 44ab876ff..d140e36bf 100644 --- a/internal/cmd/public-ip/describe/describe_test.go +++ b/internal/cmd/public-ip/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +39,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, PublicIpId: testPublicIpId, } @@ -57,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetPublicIPRequest)) iaas.ApiGetPublicIPRequest { - request := testClient.GetPublicIP(testCtx, testProjectId, testPublicIpId) + request := testClient.GetPublicIP(testCtx, testProjectId, testRegion, testPublicIpId) for _, mod := range mods { mod(&request) } @@ -101,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +144,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -234,7 +194,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.publicIp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/public-ip/disassociate/disassociate.go b/internal/cmd/public-ip/disassociate/disassociate.go index f15bd7efd..fae9afa0e 100644 --- a/internal/cmd/public-ip/disassociate/disassociate.go +++ b/internal/cmd/public-ip/disassociate/disassociate.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -12,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -26,7 +29,7 @@ type inputModel struct { PublicIpId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("disassociate %s", publicIpIdArg), Short: "Disassociates a Public IP from a network interface or a virtual IP", @@ -40,31 +43,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - publicIpLabel, associatedResourceId, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.PublicIpId) + publicIpLabel, associatedResourceId, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId) if err != nil { - p.Debug(print.ErrorLevel, "get public IP: %v", err) + params.Printer.Debug(print.ErrorLevel, "get public IP: %v", err) publicIpLabel = model.PublicIpId } else if publicIpLabel == "" { publicIpLabel = model.PublicIpId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to disassociate public IP %q from the associated resource %q?", publicIpLabel, associatedResourceId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to disassociate public IP %q from the associated resource %q?", publicIpLabel, associatedResourceId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -74,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("disassociate public IP: %w", err) } - p.Outputf("Disassociated public IP %q from the associated resource %q.\n", publicIpLabel, associatedResourceId) + params.Printer.Outputf("Disassociated public IP %q from the associated resource %q.\n", publicIpLabel, associatedResourceId) return nil }, } @@ -94,20 +95,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu PublicIpId: publicIpId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdatePublicIPRequest { - req := apiClient.UpdatePublicIP(ctx, model.ProjectId, model.PublicIpId) + req := apiClient.UpdatePublicIP(ctx, model.ProjectId, model.Region, model.PublicIpId) payload := iaas.UpdatePublicIPPayload{ NetworkInterface: iaas.NewNullableString(nil), diff --git a/internal/cmd/public-ip/disassociate/disassociate_test.go b/internal/cmd/public-ip/disassociate/disassociate_test.go index e4478b0c7..42bb505d6 100644 --- a/internal/cmd/public-ip/disassociate/disassociate_test.go +++ b/internal/cmd/public-ip/disassociate/disassociate_test.go @@ -4,16 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -35,7 +40,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -48,6 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, PublicIpId: testPublicIpId, } @@ -58,7 +65,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUpdatePublicIPRequest)) iaas.ApiUpdatePublicIPRequest { - request := testClient.UpdatePublicIP(testCtx, testProjectId, testPublicIpId) + request := testClient.UpdatePublicIP(testCtx, testProjectId, testRegion, testPublicIpId) request = request.UpdatePublicIPPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -101,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -138,7 +145,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/public-ip/list/list.go b/internal/cmd/public-ip/list/list.go index 19c225198..1888e2d1d 100644 --- a/internal/cmd/public-ip/list/list.go +++ b/internal/cmd/public-ip/list/list.go @@ -2,10 +2,12 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -32,7 +33,7 @@ type inputModel struct { LabelSelector *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all Public IPs of a project", @@ -56,15 +57,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit public-ip list --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -77,14 +78,14 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId } - p.Info("No public IPs found for project %q\n", projectLabel) + params.Printer.Info("No public IPs found for project %q\n", projectLabel) return nil } @@ -94,7 +95,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -106,7 +107,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(labelSelectorFlag, "", "Filter by label") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -126,20 +127,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListPublicIPsRequest { - req := apiClient.ListPublicIPs(ctx, model.ProjectId) + req := apiClient.ListPublicIPs(ctx, model.ProjectId, model.Region) if model.LabelSelector != nil { req = req.LabelSelector(*model.LabelSelector) } @@ -148,24 +141,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat string, publicIps []iaas.PublicIp) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(publicIps, "", " ") - if err != nil { - return fmt.Errorf("marshal public IP: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(publicIps, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal public IP: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, publicIps, func() error { table := tables.NewTable() table.SetHeader("ID", "IP ADDRESS", "USED BY") @@ -181,5 +157,5 @@ func outputResult(p *print.Printer, outputFormat string, publicIps []iaas.Public p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/public-ip/list/list_test.go b/internal/cmd/public-ip/list/list_test.go index 0644d83e8..9a10067d9 100644 --- a/internal/cmd/public-ip/list/list_test.go +++ b/internal/cmd/public-ip/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -25,7 +30,9 @@ var testLabelSelector = "label" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", labelSelectorFlag: testLabelSelector, } @@ -40,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), LabelSelector: utils.Ptr(testLabelSelector), @@ -51,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListPublicIPsRequest)) iaas.ApiListPublicIPsRequest { - request := testClient.ListPublicIPs(testCtx, testProjectId) + request := testClient.ListPublicIPs(testCtx, testProjectId, testRegion) request = request.LabelSelector(testLabelSelector) for _, mod := range mods { mod(&request) @@ -62,6 +70,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListPublicIPsRequest)) iaas.Ap func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -85,21 +94,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -131,46 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -220,7 +190,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.publicIps); (err != nil) != tt.wantErr { diff --git a/internal/cmd/public-ip/public-ip.go b/internal/cmd/public-ip/public-ip.go index 6259759a8..77a4e3a2b 100644 --- a/internal/cmd/public-ip/public-ip.go +++ b/internal/cmd/public-ip/public-ip.go @@ -7,15 +7,16 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/disassociate" "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/ranges" "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "public-ip", Short: "Provides functionality for public IPs", @@ -23,16 +24,17 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(associate.NewCmd(p)) - cmd.AddCommand(disassociate.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(associate.NewCmd(params)) + cmd.AddCommand(disassociate.NewCmd(params)) + cmd.AddCommand(ranges.NewCmd(params)) } diff --git a/internal/cmd/public-ip/ranges/list/list.go b/internal/cmd/public-ip/ranges/list/list.go new file mode 100644 index 000000000..cf01450cf --- /dev/null +++ b/internal/cmd/public-ip/ranges/list/list.go @@ -0,0 +1,124 @@ +package list + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all STACKIT public-ip ranges", + Long: "Lists all STACKIT public-ip ranges.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Lists all STACKIT public-ip ranges`, + "$ stackit public-ip ranges list", + ), + examples.NewExample( + `Lists all STACKIT public-ip ranges, piping to a tool like fzf for interactive selection`, + "$ stackit public-ip ranges list -o pretty | fzf", + ), + examples.NewExample( + `Lists up to 10 STACKIT public-ip ranges`, + "$ stackit public-ip ranges list --limit 10", + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := apiClient.ListPublicIPRanges(ctx) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list public IP ranges: %w", err) + } + publicIpRanges := utils.GetSliceFromPointer(resp.Items) + + // Truncate output + if model.Limit != nil && len(publicIpRanges) > int(*model.Limit) { + publicIpRanges = publicIpRanges[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, publicIpRanges) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + } + + p.DebugInputModel(model) + return &model, nil +} + +func outputResult(p *print.Printer, outputFormat string, publicIpRanges []iaas.PublicNetwork) error { + return p.OutputResult(outputFormat, publicIpRanges, func() error { + if len(publicIpRanges) == 0 { + p.Outputln("No public IP ranges found") + return nil + } + + for _, item := range publicIpRanges { + if item.Cidr != nil && *item.Cidr != "" { + p.Outputln(*item.Cidr) + } + } + + return nil + }) +} diff --git a/internal/cmd/public-ip/ranges/list/list_test.go b/internal/cmd/public-ip/ranges/list/list_test.go new file mode 100644 index 000000000..1e0379a91 --- /dev/null +++ b/internal/cmd/public-ip/ranges/list/list_test.go @@ -0,0 +1,192 @@ +package list + +import ( + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func TestParseInput(t *testing.T) { + projectId := uuid.New().String() + tests := []struct { + description string + argValues []string + flagValues map[string]string + expectedModel *inputModel + isValid bool + }{ + { + description: "valid project id", + flagValues: map[string]string{ + "project-id": projectId, + }, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: projectId, + Verbosity: globalflags.InfoVerbosity, + }, + }, + isValid: true, + }, + { + description: "missing project id does not lead into error", + flagValues: map[string]string{}, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.InfoVerbosity, + }, + }, + isValid: true, + }, + { + description: "valid input with limit", + flagValues: map[string]string{ + "limit": "10", + }, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.InfoVerbosity, + }, + Limit: utils.Ptr(int64(10)), + }, + isValid: true, + }, + { + description: "valid input without limit", + flagValues: map[string]string{}, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.InfoVerbosity, + }, + }, + isValid: true, + }, + { + description: "invalid limit (zero)", + flagValues: map[string]string{ + "limit": "0", + }, + expectedModel: nil, + isValid: false, + }, + { + description: "invalid limit (negative)", + flagValues: map[string]string{ + "limit": "-1", + }, + expectedModel: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestOutputResult(t *testing.T) { + tests := []struct { + name string + outputFormat string + publicIpRanges []iaas.PublicNetwork + expectedOutput string + wantErr bool + }{ + { + name: "JSON output single", + outputFormat: "json", + publicIpRanges: []iaas.PublicNetwork{ + {Cidr: utils.Ptr("192.168.0.0/24")}, + }, + wantErr: false, + }, + { + name: "JSON output multiple", + outputFormat: "json", + publicIpRanges: []iaas.PublicNetwork{ + {Cidr: utils.Ptr("192.168.0.0/24")}, + {Cidr: utils.Ptr("192.167.0.0/24")}, + }, + wantErr: false, + }, + { + name: "YAML output single", + outputFormat: "yaml", + publicIpRanges: []iaas.PublicNetwork{ + {Cidr: utils.Ptr("192.168.0.0/24")}, + }, + wantErr: false, + }, + { + name: "YAML output multiple", + outputFormat: "yaml", + publicIpRanges: []iaas.PublicNetwork{ + {Cidr: utils.Ptr("192.168.0.0/24")}, + {Cidr: utils.Ptr("192.167.0.0/24")}, + }, + wantErr: false, + }, + { + name: "pretty output single", + outputFormat: "pretty", + publicIpRanges: []iaas.PublicNetwork{ + {Cidr: utils.Ptr("192.168.0.0/24")}, + }, + wantErr: false, + }, + { + name: "pretty output multiple", + outputFormat: "pretty", + publicIpRanges: []iaas.PublicNetwork{ + {Cidr: utils.Ptr("192.168.0.0/24")}, + {Cidr: utils.Ptr("192.167.0.0/24")}, + }, + wantErr: false, + }, + { + name: "default output", + outputFormat: "", + publicIpRanges: []iaas.PublicNetwork{ + {Cidr: utils.Ptr("192.168.0.0/24")}, + }, + wantErr: false, + }, + { + name: "empty list", + outputFormat: "json", + publicIpRanges: []iaas.PublicNetwork{}, + wantErr: false, + }, + { + name: "nil CIDR", + outputFormat: "pretty", + publicIpRanges: []iaas.PublicNetwork{ + {Cidr: nil}, + {Cidr: utils.Ptr("192.168.0.0/24")}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + err := outputResult(p, tt.outputFormat, tt.publicIpRanges) + if (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/public-ip/ranges/ranges.go b/internal/cmd/public-ip/ranges/ranges.go new file mode 100644 index 000000000..5978bbbb1 --- /dev/null +++ b/internal/cmd/public-ip/ranges/ranges.go @@ -0,0 +1,26 @@ +package ranges + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/ranges/list" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "ranges", + Short: "Provides functionality for STACKIT public-ip ranges", + Long: "Provides functionality for STACKIT public-ip ranges", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) +} diff --git a/internal/cmd/public-ip/update/update.go b/internal/cmd/public-ip/update/update.go index d3bd1a86c..bdecb7dfc 100644 --- a/internal/cmd/public-ip/update/update.go +++ b/internal/cmd/public-ip/update/update.go @@ -2,14 +2,14 @@ package update import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -18,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -33,7 +32,7 @@ type inputModel struct { Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", publicIpIdArg), Short: "Updates a Public IP", @@ -51,29 +50,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.PublicIpId) + publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId) if err != nil { - p.Debug(print.ErrorLevel, "get public IP: %v", err) + params.Printer.Debug(print.ErrorLevel, "get public IP: %v", err) publicIpLabel = model.PublicIpId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update public IP %q?", publicIpLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update public IP %q?", publicIpLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -83,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update public IP: %w", err) } - return outputResult(p, model, publicIpLabel, resp) + return outputResult(params.Printer, model, publicIpLabel, resp) }, } configureFlags(cmd) @@ -105,7 +102,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu labels := flags.FlagToStringToStringPointer(p, cmd, labelFlag) if labels == nil { - return nil, &errors.EmptyUpdateError{} + return nil, &cliErr.EmptyUpdateError{} } model := inputModel{ @@ -114,57 +111,23 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Labels: labels, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdatePublicIPRequest { - req := apiClient.UpdatePublicIP(ctx, model.ProjectId, model.PublicIpId) - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } + req := apiClient.UpdatePublicIP(ctx, model.ProjectId, model.Region, model.PublicIpId) payload := iaas.UpdatePublicIPPayload{ - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } return req.UpdatePublicIPPayload(payload) } func outputResult(p *print.Printer, model *inputModel, publicIpLabel string, publicIp *iaas.PublicIp) error { - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(publicIp, "", " ") - if err != nil { - return fmt.Errorf("marshal public IP: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(publicIp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal public IP: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, publicIp, func() error { p.Outputf("Updated public IP %q.\n", publicIpLabel) return nil - } + }) } diff --git a/internal/cmd/public-ip/update/update_test.go b/internal/cmd/public-ip/update/update_test.go index 9590a858a..36514978f 100644 --- a/internal/cmd/public-ip/update/update_test.go +++ b/internal/cmd/public-ip/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +40,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - labelFlag: "key=value", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + labelFlag: "key=value", } for _, mod := range mods { mod(flagValues) @@ -50,6 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, PublicIpId: testPublicIpId, Labels: utils.Ptr(map[string]string{ @@ -63,7 +70,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUpdatePublicIPRequest)) iaas.ApiUpdatePublicIPRequest { - request := testClient.UpdatePublicIP(testCtx, testProjectId, testPublicIpId) + request := testClient.UpdatePublicIP(testCtx, testProjectId, testRegion, testPublicIpId) request = request.UpdatePublicIPPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -108,7 +115,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -116,7 +123,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -124,7 +131,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -145,7 +152,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/quota/list/list.go b/internal/cmd/quota/list/list.go index 2e7a307c4..ebb1ca353 100644 --- a/internal/cmd/quota/list/list.go +++ b/internal/cmd/quota/list/list.go @@ -2,12 +2,14 @@ package list import ( "context" - "encoding/json" "fmt" "strconv" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,14 +18,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists quotas", @@ -35,22 +36,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit quota list`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } else if projectLabel == "" { projectLabel = model.ProjectId @@ -65,9 +66,9 @@ func NewCmd(p *print.Printer) *cobra.Command { } if items := response.Quotas; items == nil { - p.Info("No quotas found for project %q", projectLabel) + params.Printer.Info("No quotas found for project %q", projectLabel) } else { - if err := outputResult(p, model.OutputFormat, items); err != nil { + if err := outputResult(params.Printer, model.OutputFormat, items); err != nil { return fmt.Errorf("output quotas: %w", err) } } @@ -79,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -89,20 +90,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListQuotasRequest { - request := apiClient.ListQuotas(ctx, model.ProjectId) + request := apiClient.ListQuotas(ctx, model.ProjectId, model.Region) return request } @@ -111,25 +104,7 @@ func outputResult(p *print.Printer, outputFormat string, quotas *iaas.QuotaList) if quotas == nil { return fmt.Errorf("quotas is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(quotas, "", " ") - if err != nil { - return fmt.Errorf("marshal quota list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(quotas, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal quota list: %w", err) - } - p.Outputln(string(details)) - - return nil - - default: + return p.OutputResult(outputFormat, quotas, func() error { table := tables.NewTable() table.SetHeader("NAME", "LIMIT", "CURRENT USAGE", "PERCENT") if val := quotas.BackupGigabytes; val != nil { @@ -174,7 +149,7 @@ func outputResult(p *print.Printer, outputFormat string, quotas *iaas.QuotaList) } return nil - } + }) } func conv(n *int64) string { diff --git a/internal/cmd/quota/list/list_test.go b/internal/cmd/quota/list/list_test.go index 1daa3fcff..b508a5c94 100644 --- a/internal/cmd/quota/list/list_test.go +++ b/internal/cmd/quota/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -25,7 +30,8 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -35,7 +41,11 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, } for _, mod := range mods { mod(model) @@ -44,7 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListQuotasRequest)) iaas.ApiListQuotasRequest { - request := testClient.ListQuotas(testCtx, testProjectId) + request := testClient.ListQuotas(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -54,6 +64,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListQuotasRequest)) iaas.ApiLi func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -72,21 +83,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -94,44 +105,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -187,7 +161,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.quotas); (err != nil) != tt.wantErr { diff --git a/internal/cmd/quota/quota.go b/internal/cmd/quota/quota.go index bd71be405..ed65097d2 100644 --- a/internal/cmd/quota/quota.go +++ b/internal/cmd/quota/quota.go @@ -3,14 +3,14 @@ package quota import ( "github.com/stackitcloud/stackit-cli/internal/cmd/quota/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "quota", Short: "Manage server quotas", @@ -18,12 +18,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand( - list.NewCmd(p), + list.NewCmd(params), ) } diff --git a/internal/cmd/rabbitmq/credentials/create/create.go b/internal/cmd/rabbitmq/credentials/create/create.go index bc4e069f4..8fcf54748 100644 --- a/internal/cmd/rabbitmq/credentials/create/create.go +++ b/internal/cmd/rabbitmq/credentials/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/client" rabbitmqUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) const ( @@ -30,7 +31,7 @@ type inputModel struct { ShowPassword bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates credentials for a RabbitMQ instance", @@ -44,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create credentials for a RabbitMQ instance and show the password in the output`, "$ stackit rabbitmq credentials create --instance-id xxx --show-password"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create RabbitMQ credentials: %w", err) } - return outputResult(p, *model, instanceLabel, resp) + return outputResult(params.Printer, *model, instanceLabel, resp) }, } configureFlags(cmd) @@ -93,7 +92,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -105,15 +104,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ShowPassword: flags.FlagToBoolValue(p, cmd, showPasswordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -138,24 +129,8 @@ func outputResult(p *print.Printer, model inputModel, instanceLabel string, resp } resp.Raw.Credentials.Password = utils.Ptr("hidden") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal RabbitMQ credentials: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal RabbitMQ credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, utils.PtrString(resp.Id)) // The username field cannot be set by the user so we only display it if it's not returned empty if resp.HasRaw() && resp.Raw.Credentials != nil { @@ -172,5 +147,5 @@ func outputResult(p *print.Printer, model inputModel, instanceLabel string, resp } p.Outputf("URI: %s\n", utils.PtrString(resp.Uri)) return nil - } + }) } diff --git a/internal/cmd/rabbitmq/credentials/create/create_test.go b/internal/cmd/rabbitmq/credentials/create/create_test.go index 1c053d029..186556aa4 100644 --- a/internal/cmd/rabbitmq/credentials/create/create_test.go +++ b/internal/cmd/rabbitmq/credentials/create/create_test.go @@ -4,16 +4,18 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -23,8 +25,8 @@ var testInstanceId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -57,6 +59,7 @@ func fixtureRequest(mods ...func(request *rabbitmq.ApiCreateCredentialsRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -85,21 +88,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -128,46 +131,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -243,7 +207,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/rabbitmq/credentials/credentials.go b/internal/cmd/rabbitmq/credentials/credentials.go index 38ec2c552..2f7c435e2 100644 --- a/internal/cmd/rabbitmq/credentials/credentials.go +++ b/internal/cmd/rabbitmq/credentials/credentials.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/credentials/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/credentials/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", Short: "Provides functionality for RabbitMQ credentials", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/rabbitmq/credentials/delete/delete.go b/internal/cmd/rabbitmq/credentials/delete/delete.go index 682a0eed8..a30d2e9d0 100644 --- a/internal/cmd/rabbitmq/credentials/delete/delete.go +++ b/internal/cmd/rabbitmq/credentials/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialsIdArg), Short: "Deletes credentials of a RabbitMQ instance", @@ -43,35 +45,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } credentialsLabel, err := rabbitmqUtils.GetCredentialsUsername(ctx, apiClient, model.ProjectId, model.InstanceId, model.CredentialsId) if err != nil { - p.Debug(print.ErrorLevel, "get credentials user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials user name: %v", err) credentialsLabel = model.CredentialsId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,7 +81,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete RabbitMQ credentials: %w", err) } - p.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) + params.Printer.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) return nil }, } @@ -110,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/rabbitmq/credentials/delete/delete_test.go b/internal/cmd/rabbitmq/credentials/delete/delete_test.go index 24739b018..176138ad9 100644 --- a/internal/cmd/rabbitmq/credentials/delete/delete_test.go +++ b/internal/cmd/rabbitmq/credentials/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,8 +13,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -35,8 +33,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -104,7 +102,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +110,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +118,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -164,54 +162,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/rabbitmq/credentials/describe/describe.go b/internal/cmd/rabbitmq/credentials/describe/describe.go index 85159848d..e17a39f73 100644 --- a/internal/cmd/rabbitmq/credentials/describe/describe.go +++ b/internal/cmd/rabbitmq/credentials/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", credentialsIdArg), Short: "Shows details of credentials of a RabbitMQ instance", @@ -48,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe RabbitMQ credentials: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -94,15 +94,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -115,24 +107,8 @@ func outputResult(p *print.Printer, outputFormat string, credentials *rabbitmq.C if credentials == nil { return fmt.Errorf("no response passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal RabbitMQ credentials: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal RabbitMQ credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, credentials, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(credentials.Id)) table.AddSeparator() @@ -152,5 +128,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials *rabbitmq.C } return nil - } + }) } diff --git a/internal/cmd/rabbitmq/credentials/describe/describe_test.go b/internal/cmd/rabbitmq/credentials/describe/describe_test.go index ef4d23107..5372405e2 100644 --- a/internal/cmd/rabbitmq/credentials/describe/describe_test.go +++ b/internal/cmd/rabbitmq/credentials/describe/describe_test.go @@ -4,16 +4,18 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -34,8 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, } for _, mod := range mods { mod(flagValues) @@ -103,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -111,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -119,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -163,54 +165,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -270,7 +225,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/rabbitmq/credentials/list/list.go b/internal/cmd/rabbitmq/credentials/list/list.go index e9b03d199..3c8b44a3c 100644 --- a/internal/cmd/rabbitmq/credentials/list/list.go +++ b/internal/cmd/rabbitmq/credentials/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( rabbitmqUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) const ( @@ -31,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all credentials' IDs for a RabbitMQ instance", @@ -48,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 credentials' IDs for a RabbitMQ instance`, "$ stackit rabbitmq credentials list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,22 +68,20 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("list RabbitMQ credentials: %w", err) } - credentials := *resp.CredentialsList - if len(credentials) == 0 { - instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) - if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) - instanceLabel = model.InstanceId - } - p.Info("No credentials found for instance %q\n", instanceLabel) - return nil + credentials := resp.GetCredentialsList() + + instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId } // Truncate output if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentials) + + return outputResult(params.Printer, model.OutputFormat, instanceLabel, credentials) }, } configureFlags(cmd) @@ -97,7 +96,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -117,15 +116,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -134,25 +125,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.AP return req } -func outputResult(p *print.Printer, outputFormat string, credentials []rabbitmq.CredentialsListItem) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal RabbitMQ credentials list: %w", err) +func outputResult(p *print.Printer, outputFormat, instanceLabel string, credentials []rabbitmq.CredentialsListItem) error { + return p.OutputResult(outputFormat, credentials, func() error { + if len(credentials) == 0 { + p.Outputf("No credentials found for instance %q\n", instanceLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal RabbitMQ credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("ID") for i := range credentials { @@ -165,5 +144,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials []rabbitmq. } return nil - } + }) } diff --git a/internal/cmd/rabbitmq/credentials/list/list_test.go b/internal/cmd/rabbitmq/credentials/list/list_test.go index 431a51a08..8dda0a05d 100644 --- a/internal/cmd/rabbitmq/credentials/list/list_test.go +++ b/internal/cmd/rabbitmq/credentials/list/list_test.go @@ -4,17 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -24,9 +26,9 @@ 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", + globalflags.ProjectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -60,6 +62,7 @@ func fixtureRequest(mods ...func(request *rabbitmq.ApiListCredentialsRequest)) r func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -78,21 +81,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -135,46 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -209,8 +173,9 @@ func TestBuildRequest(t *testing.T) { func Test_outputResult(t *testing.T) { type args struct { - outputFormat string - credentials []rabbitmq.CredentialsListItem + outputFormat string + instanceLabel string + credentials []rabbitmq.CredentialsListItem } tests := []struct { name string @@ -234,10 +199,10 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.credentials); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/rabbitmq/instance/create/create.go b/internal/cmd/rabbitmq/instance/create/create.go index 3ea8bfda1..cb2bf470a 100644 --- a/internal/cmd/rabbitmq/instance/create/create.go +++ b/internal/cmd/rabbitmq/instance/create/create.go @@ -2,12 +2,12 @@ package create import ( "context" - "encoding/json" "errors" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -57,7 +57,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a RabbitMQ instance", @@ -74,31 +74,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a RabbitMQ instance with name "my-instance" and specify IP range which is allowed to access it`, "$ stackit rabbitmq instance create --name my-instance --plan-id xxx --acl 1.2.3.0/24"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create an RabbitMQ instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create an RabbitMQ instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -118,16 +116,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for RabbitMQ instance creation: %w", err) } - s.Stop() } - return outputResult(p, model, projectLabel, instanceId, resp) + return outputResult(params.Printer, model, projectLabel, instanceId, resp) }, } configureFlags(cmd) @@ -152,7 +150,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -189,15 +187,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -267,29 +257,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel, instanceId return fmt.Errorf("no response passed") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal RabbitMQ instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal RabbitMQ instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Created" if model.Async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, instanceId) return nil - } + }) } diff --git a/internal/cmd/rabbitmq/instance/create/create_test.go b/internal/cmd/rabbitmq/instance/create/create_test.go index 5faeb3257..726bcacb3 100644 --- a/internal/cmd/rabbitmq/instance/create/create_test.go +++ b/internal/cmd/rabbitmq/instance/create/create_test.go @@ -5,17 +5,20 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -43,17 +46,17 @@ var testMonitoringInstanceId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - instanceNameFlag: "example-name", - enableMonitoringFlag: "true", - graphiteFlag: "example-graphite", - metricsFrequencyFlag: "100", - metricsPrefixFlag: "example-prefix", - monitoringInstanceIdFlag: testMonitoringInstanceId, - pluginFlag: "example-plugin", - sgwAclFlag: "198.51.100.14/24", - syslogFlag: "example-syslog", - planIdFlag: testPlanId, + globalflags.ProjectIdFlag: testProjectId, + instanceNameFlag: "example-name", + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringInstanceId, + pluginFlag: "example-plugin", + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, } for _, mod := range mods { mod(flagValues) @@ -109,6 +112,7 @@ func fixtureRequest(mods ...func(request *rabbitmq.ApiCreateInstanceRequest)) ra func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string sgwAclValues []string pluginValues []string @@ -144,9 +148,9 @@ func TestParseInput(t *testing.T) { { description: "required fields only", flagValues: map[string]string{ - projectIdFlag: testProjectId, - instanceNameFlag: "example-name", - planIdFlag: testPlanId, + globalflags.ProjectIdFlag: testProjectId, + instanceNameFlag: "example-name", + planIdFlag: testPlanId, }, isValid: true, expectedModel: &inputModel{ @@ -161,13 +165,13 @@ func TestParseInput(t *testing.T) { { description: "zero values", flagValues: map[string]string{ - projectIdFlag: testProjectId, - planIdFlag: testPlanId, - instanceNameFlag: "", - enableMonitoringFlag: "false", - graphiteFlag: "", - metricsFrequencyFlag: "0", - metricsPrefixFlag: "", + globalflags.ProjectIdFlag: testProjectId, + planIdFlag: testPlanId, + instanceNameFlag: "", + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", }, isValid: true, expectedModel: &inputModel{ @@ -186,21 +190,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -275,76 +279,11 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.sgwAclValues { - err := cmd.Flags().Set(sgwAclFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) - } - } - - for _, value := range tt.pluginValues { - err := cmd.Flags().Set(pluginFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", pluginFlag, value, err) - } - } - - for _, value := range tt.syslogValues { - err := cmd.Flags().Set(syslogFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) - } - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + sgwAclFlag: tt.sgwAclValues, + syslogFlag: tt.syslogValues, + pluginFlag: tt.pluginValues, + }, tt.isValid) }) } } @@ -521,7 +460,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, &tt.args.model, tt.args.projectLabel, tt.args.instanceId, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/rabbitmq/instance/delete/delete.go b/internal/cmd/rabbitmq/instance/delete/delete.go index 66a7b578e..ec5c35104 100644 --- a/internal/cmd/rabbitmq/instance/delete/delete.go +++ b/internal/cmd/rabbitmq/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a RabbitMQ instance", @@ -41,29 +43,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -75,20 +75,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for RabbitMQ instance deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -108,15 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/rabbitmq/instance/delete/delete_test.go b/internal/cmd/rabbitmq/instance/delete/delete_test.go index d9b32ef81..4ffdd36a8 100644 --- a/internal/cmd/rabbitmq/instance/delete/delete_test.go +++ b/internal/cmd/rabbitmq/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,8 +13,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -34,7 +32,7 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, } for _, mod := range mods { mod(flagValues) @@ -101,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +115,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/rabbitmq/instance/describe/describe.go b/internal/cmd/rabbitmq/instance/describe/describe.go index c65f67ed3..8385bfe7e 100644 --- a/internal/cmd/rabbitmq/instance/describe/describe.go +++ b/internal/cmd/rabbitmq/instance/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of a RabbitMQ instance", @@ -46,12 +46,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read RabbitMQ instance: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -82,15 +82,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -103,24 +95,8 @@ func outputResult(p *print.Printer, outputFormat string, instance *rabbitmq.Inst if instance == nil { return fmt.Errorf("no instance passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal RabbitMQ instance: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal RabbitMQ instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(instance.InstanceId)) table.AddSeparator() @@ -150,5 +126,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *rabbitmq.Inst } return nil - } + }) } diff --git a/internal/cmd/rabbitmq/instance/describe/describe_test.go b/internal/cmd/rabbitmq/instance/describe/describe_test.go index 4b9420995..d48cb0a36 100644 --- a/internal/cmd/rabbitmq/instance/describe/describe_test.go +++ b/internal/cmd/rabbitmq/instance/describe/describe_test.go @@ -4,16 +4,18 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -33,7 +35,7 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, } for _, mod := range mods { mod(flagValues) @@ -100,7 +102,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -108,7 +110,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -116,7 +118,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,54 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -250,7 +205,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/rabbitmq/instance/instance.go b/internal/cmd/rabbitmq/instance/instance.go index b9a73a3e0..fcbc2b7d9 100644 --- a/internal/cmd/rabbitmq/instance/instance.go +++ b/internal/cmd/rabbitmq/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for RabbitMQ instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/rabbitmq/instance/list/list.go b/internal/cmd/rabbitmq/instance/list/list.go index 3ade03661..70e47500e 100644 --- a/internal/cmd/rabbitmq/instance/list/list.go +++ b/internal/cmd/rabbitmq/instance/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all RabbitMQ instances", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 RabbitMQ instances`, "$ stackit rabbitmq instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,15 +66,12 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get RabbitMQ instances: %w", err) } - instances := *resp.Instances - if len(instances) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No instances found for project %q\n", projectLabel) - return nil + instances := resp.GetInstances() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } // Truncate output @@ -81,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) }, } @@ -93,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.AP return req } -func outputResult(p *print.Printer, outputFormat string, instances []rabbitmq.Instance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal RabbitMQ instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal RabbitMQ instance list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []rabbitmq.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE") for i := range instances { @@ -174,5 +152,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []rabbitmq.In } return nil - } + }) } diff --git a/internal/cmd/rabbitmq/instance/list/list_test.go b/internal/cmd/rabbitmq/instance/list/list_test.go index 50bdcf4bd..10e461d71 100644 --- a/internal/cmd/rabbitmq/instance/list/list_test.go +++ b/internal/cmd/rabbitmq/instance/list/list_test.go @@ -4,18 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -24,8 +25,8 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -58,6 +59,7 @@ func fixtureRequest(mods ...func(request *rabbitmq.ApiListInstancesRequest)) rab func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -76,21 +78,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -112,48 +114,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -189,6 +150,7 @@ func TestBuildRequest(t *testing.T) { func Test_outputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string instances []rabbitmq.Instance } tests := []struct { @@ -213,10 +175,10 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instances); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/rabbitmq/instance/update/update.go b/internal/cmd/rabbitmq/instance/update/update.go index 67144235d..b58590a6e 100644 --- a/internal/cmd/rabbitmq/instance/update/update.go +++ b/internal/cmd/rabbitmq/instance/update/update.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -56,7 +58,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates a RabbitMQ instance", @@ -72,29 +74,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := rabbitmqUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -114,20 +114,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for RabbitMQ instance update: %w", err) } - s.Stop() } operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -199,15 +199,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/rabbitmq/instance/update/update_test.go b/internal/cmd/rabbitmq/instance/update/update_test.go index c2d92bc6f..2120ac96d 100644 --- a/internal/cmd/rabbitmq/instance/update/update_test.go +++ b/internal/cmd/rabbitmq/instance/update/update_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -15,8 +17,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -57,16 +57,16 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - enableMonitoringFlag: "true", - graphiteFlag: "example-graphite", - metricsFrequencyFlag: "100", - metricsPrefixFlag: "example-prefix", - monitoringInstanceIdFlag: testMonitoringInstanceId, - pluginFlag: "example-plugin", - sgwAclFlag: "198.51.100.14/24", - syslogFlag: "example-syslog", - planIdFlag: testPlanId, + globalflags.ProjectIdFlag: testProjectId, + enableMonitoringFlag: "true", + graphiteFlag: "example-graphite", + metricsFrequencyFlag: "100", + metricsPrefixFlag: "example-prefix", + monitoringInstanceIdFlag: testMonitoringInstanceId, + pluginFlag: "example-plugin", + sgwAclFlag: "198.51.100.14/24", + syslogFlag: "example-syslog", + planIdFlag: testPlanId, } for _, mod := range mods { mod(flagValues) @@ -158,7 +158,7 @@ func TestParseInput(t *testing.T) { description: "required flags only (no values to update)", argValues: fixtureArgValues(), flagValues: map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, }, isValid: false, expectedModel: &inputModel{ @@ -173,12 +173,12 @@ func TestParseInput(t *testing.T) { description: "zero values", argValues: fixtureArgValues(), flagValues: map[string]string{ - projectIdFlag: testProjectId, - planIdFlag: testPlanId, - enableMonitoringFlag: "false", - graphiteFlag: "", - metricsFrequencyFlag: "0", - metricsPrefixFlag: "", + globalflags.ProjectIdFlag: testProjectId, + planIdFlag: testPlanId, + enableMonitoringFlag: "false", + graphiteFlag: "", + metricsFrequencyFlag: "0", + metricsPrefixFlag: "", }, isValid: true, expectedModel: &inputModel{ @@ -198,7 +198,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -206,7 +206,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -214,7 +214,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -294,7 +294,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/rabbitmq/plans/plans.go b/internal/cmd/rabbitmq/plans/plans.go index 1b84f3029..cc9cc3e1b 100644 --- a/internal/cmd/rabbitmq/plans/plans.go +++ b/internal/cmd/rabbitmq/plans/plans.go @@ -2,10 +2,10 @@ package plans import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "plans", Short: "Lists all RabbitMQ service plans", @@ -47,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 RabbitMQ service plans`, "$ stackit rabbitmq plans --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,15 +66,12 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get RabbitMQ service plans: %w", err) } - plans := *resp.Offerings - if len(plans) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No plans found for project %q\n", projectLabel) - return nil + plans := resp.GetOfferings() + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId } // Truncate output @@ -82,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { plans = plans[:*model.Limit] } - return outputResult(p, model.OutputFormat, plans) + return outputResult(params.Printer, model.OutputFormat, projectLabel, plans) }, } @@ -94,7 +91,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -113,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -130,25 +119,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.AP return req } -func outputResult(p *print.Printer, outputFormat string, plans []rabbitmq.Offering) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(plans, "", " ") - if err != nil { - return fmt.Errorf("marshal RabbitMQ plans: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, plans []rabbitmq.Offering) error { + return p.OutputResult(outputFormat, plans, func() error { + if len(plans) == 0 { + p.Outputf("No plans found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(plans, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal RabbitMQ plans: %w", err) - } - p.Outputln(string(details)) - - return nil - default: table := tables.NewTable() table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { @@ -174,5 +151,5 @@ func outputResult(p *print.Printer, outputFormat string, plans []rabbitmq.Offeri } return nil - } + }) } diff --git a/internal/cmd/rabbitmq/plans/plans_test.go b/internal/cmd/rabbitmq/plans/plans_test.go index ab8966a20..e83a33072 100644 --- a/internal/cmd/rabbitmq/plans/plans_test.go +++ b/internal/cmd/rabbitmq/plans/plans_test.go @@ -4,18 +4,19 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/rabbitmq" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) -var projectIdFlag = globalflags.ProjectIdFlag - type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") @@ -24,8 +25,8 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -58,6 +59,7 @@ func fixtureRequest(mods ...func(request *rabbitmq.ApiListOfferingsRequest)) rab func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -76,21 +78,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -112,48 +114,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -189,6 +150,7 @@ func TestBuildRequest(t *testing.T) { func Test_outputResult(t *testing.T) { type args struct { outputFormat string + projectLabel string plans []rabbitmq.Offering } tests := []struct { @@ -213,10 +175,10 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.plans); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.plans); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/rabbitmq/rabbitmq.go b/internal/cmd/rabbitmq/rabbitmq.go index 26b5db9bb..23099b758 100644 --- a/internal/cmd/rabbitmq/rabbitmq.go +++ b/internal/cmd/rabbitmq/rabbitmq.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq/plans" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "rabbitmq", Short: "Provides functionality for RabbitMQ", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(plans.NewCmd(p)) - cmd.AddCommand(credentials.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) } diff --git a/internal/cmd/redis/credentials/create/create.go b/internal/cmd/redis/credentials/create/create.go index 9b2098344..a3e3e8e63 100644 --- a/internal/cmd/redis/credentials/create/create.go +++ b/internal/cmd/redis/credentials/create/create.go @@ -2,10 +2,10 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -31,7 +31,7 @@ type inputModel struct { ShowPassword bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates credentials for a Redis instance", @@ -45,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create credentials for a Redis instance and show the password in the output`, "$ stackit redis credentials create --instance-id xxx --show-password"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create credentials for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -79,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Redis credentials: %w", err) } - return outputResult(p, *model, instanceLabel, resp) + return outputResult(params.Printer, *model, instanceLabel, resp) }, } configureFlags(cmd) @@ -94,7 +92,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -106,15 +104,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ShowPassword: flags.FlagToBoolValue(p, cmd, showPasswordFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -140,24 +130,7 @@ func outputResult(p *print.Printer, model inputModel, instanceLabel string, resp resp.Raw.Credentials.Password = utils.Ptr("hidden") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal Redis credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Redis credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, utils.PtrString(resp.Id)) // The username field cannot be set by the user, so we only display it if it's not returned empty if resp.HasRaw() && resp.Raw.Credentials != nil { @@ -174,5 +147,5 @@ func outputResult(p *print.Printer, model inputModel, instanceLabel string, resp } p.Outputf("URI: %s\n", utils.PtrString(resp.Uri)) return nil - } + }) } diff --git a/internal/cmd/redis/credentials/create/create_test.go b/internal/cmd/redis/credentials/create/create_test.go index 511b066ad..ddb64fac1 100644 --- a/internal/cmd/redis/credentials/create/create_test.go +++ b/internal/cmd/redis/credentials/create/create_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -57,6 +61,7 @@ func fixtureRequest(mods ...func(request *redis.ApiCreateCredentialsRequest)) re func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -128,46 +133,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -223,7 +189,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/redis/credentials/credentials.go b/internal/cmd/redis/credentials/credentials.go index 42e6226da..41a7b4f92 100644 --- a/internal/cmd/redis/credentials/credentials.go +++ b/internal/cmd/redis/credentials/credentials.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/redis/credentials/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/redis/credentials/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", Short: "Provides functionality for Redis credentials", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/redis/credentials/delete/delete.go b/internal/cmd/redis/credentials/delete/delete.go index 4012c6b3f..496a43dd2 100644 --- a/internal/cmd/redis/credentials/delete/delete.go +++ b/internal/cmd/redis/credentials/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", credentialsIdArg), Short: "Deletes credentials of a Redis instance", @@ -43,35 +45,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } credentialsLabel, err := redisUtils.GetCredentialsUsername(ctx, apiClient, model.ProjectId, model.InstanceId, model.CredentialsId) if err != nil { - p.Debug(print.ErrorLevel, "get credentials user name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get credentials user name: %v", err) credentialsLabel = model.CredentialsId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete credentials %s of instance %q? (This cannot be undone)", credentialsLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,7 +81,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Redis credentials: %w", err) } - p.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) + params.Printer.Info("Deleted credentials %s of instance %q\n", credentialsLabel, instanceLabel) return nil }, } @@ -110,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/redis/credentials/delete/delete_test.go b/internal/cmd/redis/credentials/delete/delete_test.go index 716bfedfe..08960a15d 100644 --- a/internal/cmd/redis/credentials/delete/delete_test.go +++ b/internal/cmd/redis/credentials/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -164,54 +164,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/redis/credentials/describe/describe.go b/internal/cmd/redis/credentials/describe/describe.go index 7a6dc78e4..115f23f4b 100644 --- a/internal/cmd/redis/credentials/describe/describe.go +++ b/internal/cmd/redis/credentials/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +32,7 @@ type inputModel struct { CredentialsId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", credentialsIdArg), Short: "Shows details of credentials of a Redis instance", @@ -48,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe Redis credentials: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -94,15 +94,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CredentialsId: credentialsId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -115,24 +107,8 @@ func outputResult(p *print.Printer, outputFormat string, credentials *redis.Cred if credentials == nil { return fmt.Errorf("no credentials passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal Redis credentials: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Redis credentials: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, credentials, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(credentials.Id)) table.AddSeparator() @@ -152,5 +128,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials *redis.Cred } return nil - } + }) } diff --git a/internal/cmd/redis/credentials/describe/describe_test.go b/internal/cmd/redis/credentials/describe/describe_test.go index c6f837491..0697f37c8 100644 --- a/internal/cmd/redis/credentials/describe/describe_test.go +++ b/internal/cmd/redis/credentials/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -163,54 +167,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -267,7 +224,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { diff --git a/internal/cmd/redis/credentials/list/list.go b/internal/cmd/redis/credentials/list/list.go index a83492451..536ef13c7 100644 --- a/internal/cmd/redis/credentials/list/list.go +++ b/internal/cmd/redis/credentials/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( redisUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/redis" ) const ( @@ -31,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all credentials' IDs for a Redis instance", @@ -48,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 credentials' IDs for a Redis instance`, "$ stackit redis credentials list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,21 +69,21 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("list Redis credentials: %w", err) } credentials := *resp.CredentialsList - if len(credentials) == 0 { - instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) - if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) - instanceLabel = model.InstanceId - } - p.Info("No credentials found for instance %q\n", instanceLabel) - return nil - } // Truncate output if model.Limit != nil && len(credentials) > int(*model.Limit) { credentials = credentials[:*model.Limit] } - return outputResult(p, model.OutputFormat, credentials) + + instanceLabel := model.InstanceId + if len(credentials) == 0 { + instanceLabel, err = redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, instanceLabel, credentials) }, } configureFlags(cmd) @@ -97,7 +98,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -117,15 +118,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -134,25 +127,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APICl return req } -func outputResult(p *print.Printer, outputFormat string, credentials []redis.CredentialsListItem) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(credentials, "", " ") - if err != nil { - return fmt.Errorf("marshal Redis credentials list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Redis credentials list: %w", err) +func outputResult(p *print.Printer, outputFormat, instanceLabel string, credentials []redis.CredentialsListItem) error { + return p.OutputResult(outputFormat, credentials, func() error { + if len(credentials) == 0 { + p.Outputf("No credentials found for instance %q\n", instanceLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID") for i := range credentials { @@ -165,5 +146,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials []redis.Cre } return nil - } + }) } diff --git a/internal/cmd/redis/credentials/list/list_test.go b/internal/cmd/redis/credentials/list/list_test.go index b771823bf..ee8b74679 100644 --- a/internal/cmd/redis/credentials/list/list_test.go +++ b/internal/cmd/redis/credentials/list/list_test.go @@ -4,13 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/redis" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -60,6 +64,7 @@ func fixtureRequest(mods ...func(request *redis.ApiListCredentialsRequest)) redi func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -135,46 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -234,10 +200,10 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.credentials); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, "dummy-instance-label", tt.args.credentials); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/redis/instance/create/create.go b/internal/cmd/redis/instance/create/create.go index 87db67741..817a769ce 100644 --- a/internal/cmd/redis/instance/create/create.go +++ b/internal/cmd/redis/instance/create/create.go @@ -2,12 +2,12 @@ package create import ( "context" - "encoding/json" "errors" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -55,7 +55,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Redis instance", @@ -72,31 +72,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a Redis instance with name "my-instance" and specify IP range which is allowed to access it`, "$ stackit redis instance create --name my-instance --plan-id xxx --acl 1.2.3.0/24"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a Redis instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a Redis instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -116,16 +114,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating instance") - _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating instance", func() error { + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for Redis instance creation: %w", err) } - s.Stop() } - return outputResult(p, model, projectLabel, instanceId, resp) + return outputResult(params.Printer, model, projectLabel, instanceId, resp) }, } configureFlags(cmd) @@ -149,7 +147,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -185,15 +183,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -261,29 +251,13 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel, instanceId if resp == nil { return fmt.Errorf("no response defined") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal Redis instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Redis instance: %w", err) - } - p.Outputln(string(details)) - return nil - default: + return p.OutputResult(model.OutputFormat, resp, func() error { operationState := "Created" if model.Async { operationState = "Triggered creation of" } p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, instanceId) return nil - } + }) } diff --git a/internal/cmd/redis/instance/create/create_test.go b/internal/cmd/redis/instance/create/create_test.go index 59ee4d259..c133ec83e 100644 --- a/internal/cmd/redis/instance/create/create_test.go +++ b/internal/cmd/redis/instance/create/create_test.go @@ -5,13 +5,17 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/redis" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -106,6 +110,7 @@ func fixtureRequest(mods ...func(request *redis.ApiCreateInstanceRequest)) redis func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string sgwAclValues []string syslogValues []string @@ -260,66 +265,10 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.sgwAclValues { - err := cmd.Flags().Set(sgwAclFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", sgwAclFlag, value, err) - } - } - - for _, value := range tt.syslogValues { - err := cmd.Flags().Set(syslogFlag, value) - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("setting flag --%s=%s: %v", syslogFlag, value, err) - } - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + sgwAclFlag: tt.sgwAclValues, + syslogFlag: tt.syslogValues, + }, tt.isValid) }) } } @@ -496,7 +445,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.instanceId, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/redis/instance/delete/delete.go b/internal/cmd/redis/instance/delete/delete.go index 902e30ada..f54412008 100644 --- a/internal/cmd/redis/instance/delete/delete.go +++ b/internal/cmd/redis/instance/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a Redis instance", @@ -41,29 +43,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -75,20 +75,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting instance") - _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting instance", func() error { + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for Redis instance deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -108,15 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/redis/instance/delete/delete_test.go b/internal/cmd/redis/instance/delete/delete_test.go index 60dc78c89..6372daa5b 100644 --- a/internal/cmd/redis/instance/delete/delete_test.go +++ b/internal/cmd/redis/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,54 +137,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/redis/instance/describe/describe.go b/internal/cmd/redis/instance/describe/describe.go index f91b4b1e6..aaa003478 100644 --- a/internal/cmd/redis/instance/describe/describe.go +++ b/internal/cmd/redis/instance/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +30,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of a Redis instance", @@ -46,12 +46,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read Redis instance: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -82,15 +82,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -103,24 +95,8 @@ func outputResult(p *print.Printer, outputFormat string, instance *redis.Instanc if instance == nil { return fmt.Errorf("no instance passed") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal Redis instance: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Redis instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(instance.InstanceId)) table.AddSeparator() @@ -150,5 +126,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *redis.Instanc } return nil - } + }) } diff --git a/internal/cmd/redis/instance/describe/describe_test.go b/internal/cmd/redis/instance/describe/describe_test.go index 1e12d6195..2a1cb3acc 100644 --- a/internal/cmd/redis/instance/describe/describe_test.go +++ b/internal/cmd/redis/instance/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -136,54 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -251,7 +208,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/redis/instance/instance.go b/internal/cmd/redis/instance/instance.go index 3518921b5..82cbe63cd 100644 --- a/internal/cmd/redis/instance/instance.go +++ b/internal/cmd/redis/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for Redis instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/redis/instance/list/list.go b/internal/cmd/redis/instance/list/list.go index 4b49c15fa..b0d207907 100644 --- a/internal/cmd/redis/instance/list/list.go +++ b/internal/cmd/redis/instance/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/redis" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all Redis instances", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 Redis instances`, "$ stackit redis instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,22 +67,21 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get Redis instances: %w", err) } instances := *resp.Instances - if len(instances) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No instances found for project %q\n", projectLabel) - return nil - } // Truncate output if model.Limit != nil && len(instances) > int(*model.Limit) { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + projectLabel := model.ProjectId + if len(instances) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, instances) }, } @@ -93,7 +93,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +112,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -129,25 +121,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APICl return req } -func outputResult(p *print.Printer, outputFormat string, instances []redis.Instance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal Redis instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Redis instance list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, instances []redis.Instance) error { + return p.OutputResult(outputFormat, instances, func() error { + if len(instances) == 0 { + p.Outputf("No instances found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - default: table := tables.NewTable() table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE") for i := range instances { @@ -171,5 +151,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []redis.Insta } return nil - } + }) } diff --git a/internal/cmd/redis/instance/list/list_test.go b/internal/cmd/redis/instance/list/list_test.go index 0338bd8a7..15250c774 100644 --- a/internal/cmd/redis/instance/list/list_test.go +++ b/internal/cmd/redis/instance/list/list_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/redis" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -58,6 +61,7 @@ func fixtureRequest(mods ...func(request *redis.ApiListInstancesRequest)) redis. func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -112,48 +116,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -212,10 +175,10 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, "dummy-project-label", tt.args.instances); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/redis/instance/update/update.go b/internal/cmd/redis/instance/update/update.go index 117dbe472..ca078eb82 100644 --- a/internal/cmd/redis/instance/update/update.go +++ b/internal/cmd/redis/instance/update/update.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -53,7 +55,7 @@ type inputModel struct { PlanId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates a Redis instance", @@ -69,29 +71,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := redisUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -111,20 +111,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating instance") - _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating instance", func() error { + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for Redis instance update: %w", err) } - s.Stop() } operationState := "Updated" if model.Async { operationState = "Triggered update of" } - p.Info("%s instance %q\n", operationState, instanceLabel) + params.Printer.Info("%s instance %q\n", operationState, instanceLabel) return nil }, } @@ -193,15 +193,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Version: version, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/redis/instance/update/update_test.go b/internal/cmd/redis/instance/update/update_test.go index 30602e593..c4bfedb8c 100644 --- a/internal/cmd/redis/instance/update/update_test.go +++ b/internal/cmd/redis/instance/update/update_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -278,7 +280,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/redis/plans/plans.go b/internal/cmd/redis/plans/plans.go index 8ae785da6..923006376 100644 --- a/internal/cmd/redis/plans/plans.go +++ b/internal/cmd/redis/plans/plans.go @@ -2,11 +2,13 @@ package plans import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/redis" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "plans", Short: "Lists all Redis service plans", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 Redis service plans`, "$ stackit redis plans --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,12 +68,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } plans := *resp.Offerings if len(plans) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No plans found for project %q\n", projectLabel) + params.Printer.Info("No plans found for project %q\n", projectLabel) return nil } @@ -81,7 +82,7 @@ func NewCmd(p *print.Printer) *cobra.Command { plans = plans[:*model.Limit] } - return outputResult(p, model.OutputFormat, plans) + return outputResult(params.Printer, model.OutputFormat, plans) }, } @@ -93,7 +94,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -112,15 +113,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -130,24 +123,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APICl } func outputResult(p *print.Printer, outputFormat string, plans []redis.Offering) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(plans, "", " ") - if err != nil { - return fmt.Errorf("marshal Redis plans: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(plans, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Redis plans: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, plans, func() error { table := tables.NewTable() table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION") for i := range plans { @@ -173,5 +149,5 @@ func outputResult(p *print.Printer, outputFormat string, plans []redis.Offering) } return nil - } + }) } diff --git a/internal/cmd/redis/plans/plans_test.go b/internal/cmd/redis/plans/plans_test.go index f850de8a1..a85d010dc 100644 --- a/internal/cmd/redis/plans/plans_test.go +++ b/internal/cmd/redis/plans/plans_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "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/redis" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/redis" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -58,6 +61,7 @@ func fixtureRequest(mods ...func(request *redis.ApiListOfferingsRequest)) redis. func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -112,48 +116,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -212,7 +175,7 @@ func Test_outputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.plans); (err != nil) != tt.wantErr { diff --git a/internal/cmd/redis/redis.go b/internal/cmd/redis/redis.go index 3b07f00c9..e0716339b 100644 --- a/internal/cmd/redis/redis.go +++ b/internal/cmd/redis/redis.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/redis/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/redis/plans" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "redis", Short: "Provides functionality for Redis", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(plans.NewCmd(p)) - cmd.AddCommand(credentials.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(plans.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 736c944f5..c120ccc76 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,16 +6,21 @@ import ( "strings" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + affinityGroups "github.com/stackitcloud/stackit-cli/internal/cmd/affinity-groups" "github.com/stackitcloud/stackit-cli/internal/cmd/auth" "github.com/stackitcloud/stackit-cli/internal/cmd/beta" configCmd "github.com/stackitcloud/stackit-cli/internal/cmd/config" "github.com/stackitcloud/stackit-cli/internal/cmd/curl" "github.com/stackitcloud/stackit-cli/internal/cmd/dns" + "github.com/stackitcloud/stackit-cli/internal/cmd/git" "github.com/stackitcloud/stackit-cli/internal/cmd/image" keypair "github.com/stackitcloud/stackit-cli/internal/cmd/key-pair" + "github.com/stackitcloud/stackit-cli/internal/cmd/kms" loadbalancer "github.com/stackitcloud/stackit-cli/internal/cmd/load-balancer" "github.com/stackitcloud/stackit-cli/internal/cmd/logme" + "github.com/stackitcloud/stackit-cli/internal/cmd/logs" "github.com/stackitcloud/stackit-cli/internal/cmd/mariadb" "github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex" "github.com/stackitcloud/stackit-cli/internal/cmd/network" @@ -52,14 +57,16 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command { cmd := &cobra.Command{ Use: "stackit", Short: "Manage STACKIT resources using the command line", - Long: "Manage STACKIT resources using the command line.\nThis CLI is in a BETA state.\nMore services and functionality will be supported soon. Your feedback is appreciated!", + Long: "Manage STACKIT resources using the command line.\nYour feedback is appreciated!", Args: args.NoArgs, SilenceErrors: true, // Error is beautified in a custom way before being printed SilenceUsage: true, DisableAutoGenTag: true, PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { p.Cmd = cmd - p.Verbosity = print.Level(globalflags.Parse(p, cmd).Verbosity) + globalFlags := globalflags.Parse(p, cmd) + p.Verbosity = print.Level(globalFlags.Verbosity) + p.AssumeYes = globalFlags.AssumeYes argsString := print.BuildDebugStrFromSlice(os.Args) p.Debug(print.DebugLevel, "arguments: %s", argsString) @@ -95,7 +102,7 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command { }, RunE: func(cmd *cobra.Command, _ []string) error { if flags.FlagToBoolValue(p, cmd, "version") { - p.Outputf("STACKIT CLI (beta)\n") + p.Outputf("STACKIT CLI\n") parsedDate, err := time.Parse(time.RFC3339, date) if err != nil { @@ -114,7 +121,10 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command { err := configureFlags(cmd) cobra.CheckErr(err) - addSubcommands(cmd, p) + addSubcommands(cmd, &types.CmdParams{ + Printer: p, + CliVersion: version, + }) // 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 @@ -154,38 +164,41 @@ func configureFlags(cmd *cobra.Command) error { return nil } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(auth.NewCmd(p)) - cmd.AddCommand(configCmd.NewCmd(p)) - cmd.AddCommand(beta.NewCmd(p)) - cmd.AddCommand(curl.NewCmd(p)) - cmd.AddCommand(dns.NewCmd(p)) - cmd.AddCommand(loadbalancer.NewCmd(p)) - cmd.AddCommand(logme.NewCmd(p)) - cmd.AddCommand(mariadb.NewCmd(p)) - cmd.AddCommand(mongodbflex.NewCmd(p)) - cmd.AddCommand(objectstorage.NewCmd(p)) - cmd.AddCommand(observability.NewCmd(p)) - cmd.AddCommand(opensearch.NewCmd(p)) - cmd.AddCommand(organization.NewCmd(p)) - cmd.AddCommand(postgresflex.NewCmd(p)) - cmd.AddCommand(project.NewCmd(p)) - cmd.AddCommand(rabbitmq.NewCmd(p)) - cmd.AddCommand(redis.NewCmd(p)) - cmd.AddCommand(secretsmanager.NewCmd(p)) - cmd.AddCommand(serviceaccount.NewCmd(p)) - cmd.AddCommand(ske.NewCmd(p)) - cmd.AddCommand(server.NewCmd(p)) - cmd.AddCommand(networkArea.NewCmd(p)) - cmd.AddCommand(network.NewCmd(p)) - cmd.AddCommand(volume.NewCmd(p)) - cmd.AddCommand(networkinterface.NewCmd(p)) - cmd.AddCommand(publicip.NewCmd(p)) - cmd.AddCommand(securitygroup.NewCmd(p)) - cmd.AddCommand(keypair.NewCmd(p)) - cmd.AddCommand(image.NewCmd(p)) - cmd.AddCommand(quota.NewCmd(p)) - cmd.AddCommand(affinityGroups.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(auth.NewCmd(params)) + cmd.AddCommand(configCmd.NewCmd(params)) + cmd.AddCommand(beta.NewCmd(params)) + cmd.AddCommand(curl.NewCmd(params)) + cmd.AddCommand(dns.NewCmd(params)) + cmd.AddCommand(loadbalancer.NewCmd(params)) + cmd.AddCommand(logme.NewCmd(params)) + cmd.AddCommand(logs.NewCmd(params)) + cmd.AddCommand(mariadb.NewCmd(params)) + cmd.AddCommand(mongodbflex.NewCmd(params)) + cmd.AddCommand(objectstorage.NewCmd(params)) + cmd.AddCommand(observability.NewCmd(params)) + cmd.AddCommand(opensearch.NewCmd(params)) + cmd.AddCommand(organization.NewCmd(params)) + cmd.AddCommand(postgresflex.NewCmd(params)) + cmd.AddCommand(project.NewCmd(params)) + cmd.AddCommand(rabbitmq.NewCmd(params)) + cmd.AddCommand(redis.NewCmd(params)) + cmd.AddCommand(secretsmanager.NewCmd(params)) + cmd.AddCommand(serviceaccount.NewCmd(params)) + cmd.AddCommand(ske.NewCmd(params)) + cmd.AddCommand(server.NewCmd(params)) + cmd.AddCommand(networkArea.NewCmd(params)) + cmd.AddCommand(network.NewCmd(params)) + cmd.AddCommand(volume.NewCmd(params)) + cmd.AddCommand(networkinterface.NewCmd(params)) + cmd.AddCommand(publicip.NewCmd(params)) + cmd.AddCommand(securitygroup.NewCmd(params)) + cmd.AddCommand(keypair.NewCmd(params)) + cmd.AddCommand(image.NewCmd(params)) + cmd.AddCommand(quota.NewCmd(params)) + cmd.AddCommand(affinityGroups.NewCmd(params)) + cmd.AddCommand(git.NewCmd(params)) + cmd.AddCommand(kms.NewCmd(params)) } // traverseCommands calls f for c and all of its children. @@ -202,6 +215,7 @@ func Execute(version, date string) { // We need to set the printer and verbosity here because the // PersistentPreRun is not called when the command is wrongly called + // In this case Printer.AssumeYes isn't set either, but `false` as default is acceptable p.Cmd = cmd p.Verbosity = print.InfoLevel diff --git a/internal/cmd/root_test.go b/internal/cmd/root_test.go index 71861a46f..1becca94e 100644 --- a/internal/cmd/root_test.go +++ b/internal/cmd/root_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/spf13/cobra" + pkgErrors "github.com/stackitcloud/stackit-cli/internal/pkg/errors" ) diff --git a/internal/cmd/secrets-manager/instance/create/create.go b/internal/cmd/secrets-manager/instance/create/create.go index 35c02682c..83662cf44 100644 --- a/internal/cmd/secrets-manager/instance/create/create.go +++ b/internal/cmd/secrets-manager/instance/create/create.go @@ -2,10 +2,12 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" "github.com/spf13/cobra" ) @@ -23,6 +24,11 @@ import ( const ( instanceNameFlag = "name" aclFlag = "acl" + + kmsKeyIdFlag = "kms-key-id" + kmsKeyringIdFlag = "kms-keyring-id" + kmsKeyVersionFlag = "kms-key-version" + kmsServiceAccountEmailFlag = "kms-service-account-email" ) type inputModel struct { @@ -30,9 +36,14 @@ type inputModel struct { InstanceName *string Acls *[]string + + KmsKeyId *string + KmsKeyringId *string + KmsKeyVersion *int64 + KmsServiceAccountEmail *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Secrets Manager instance", @@ -45,33 +56,34 @@ func NewCmd(p *print.Printer) *cobra.Command { examples.NewExample( `Create a Secrets Manager instance with name "my-instance" and specify IP range which is allowed to access it`, `$ stackit secrets-manager instance create --name my-instance --acl 1.2.3.0/24`), + examples.NewExample( + `Create a Secrets Manager instance with name "my-instance" and configure KMS key options`, + `$ stackit secrets-manager instance create --name my-instance --kms-key-id key-id --kms-keyring-id keyring-id --kms-key-version 1 --kms-service-account-email my-service-account-1234567@sa.stackit.cloud`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a Secrets Manager instance for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a Secrets Manager instance for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API to create instance @@ -94,7 +106,7 @@ If you want to retry configuring the ACLs, you can do it via: } } - return outputResult(p, model.OutputFormat, projectLabel, instanceId, resp) + return outputResult(params.Printer, model.OutputFormat, projectLabel, instanceId, resp) }, } configureFlags(cmd) @@ -105,40 +117,54 @@ func configureFlags(cmd *cobra.Command) { 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(kmsKeyIdFlag, "", "ID of the KMS key to use for encryption") + cmd.Flags().String(kmsKeyringIdFlag, "", "ID of the KMS key ring") + cmd.Flags().Int64(kmsKeyVersionFlag, 0, "Version of the KMS key") + cmd.Flags().String(kmsServiceAccountEmailFlag, "", "Service account email for KMS access") + err := flags.MarkFlagsRequired(cmd, instanceNameFlag) cobra.CheckErr(err) + + cmd.MarkFlagsRequiredTogether(kmsKeyIdFlag, kmsKeyringIdFlag, kmsKeyVersionFlag, kmsServiceAccountEmailFlag) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} } model := inputModel{ - GlobalFlagModel: globalFlags, - InstanceName: flags.FlagToStringPointer(p, cmd, instanceNameFlag), - Acls: flags.FlagToStringSlicePointer(p, cmd, aclFlag), - } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + GlobalFlagModel: globalFlags, + InstanceName: flags.FlagToStringPointer(p, cmd, instanceNameFlag), + Acls: flags.FlagToStringSlicePointer(p, cmd, aclFlag), + KmsKeyId: flags.FlagToStringPointer(p, cmd, kmsKeyIdFlag), + KmsKeyringId: flags.FlagToStringPointer(p, cmd, kmsKeyringIdFlag), + KmsKeyVersion: flags.FlagToInt64Pointer(p, cmd, kmsKeyVersionFlag), + KmsServiceAccountEmail: flags.FlagToStringPointer(p, cmd, kmsServiceAccountEmailFlag), } + p.DebugInputModel(model) return &model, nil } func buildCreateInstanceRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiCreateInstanceRequest { req := apiClient.CreateInstance(ctx, model.ProjectId) - req = req.CreateInstancePayload(secretsmanager.CreateInstancePayload{ + payload := secretsmanager.CreateInstancePayload{ Name: model.InstanceName, - }) + } + + if model.KmsKeyId != nil { + payload.KmsKey = &secretsmanager.KmsKeyPayload{ + KeyId: model.KmsKeyId, + KeyRingId: model.KmsKeyringId, + KeyVersion: model.KmsKeyVersion, + ServiceAccountEmail: model.KmsServiceAccountEmail, + } + } + + req = req.CreateInstancePayload(payload) return req } @@ -162,25 +188,8 @@ func outputResult(p *print.Printer, outputFormat, projectLabel, instanceId strin return fmt.Errorf("instance is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instance, "", " ") - if err != nil { - return fmt.Errorf("marshal Secrets Manager instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Secrets Manager instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instance, func() error { p.Outputf("Created instance for project %q. Instance ID: %s\n", projectLabel, instanceId) return nil - } + }) } diff --git a/internal/cmd/secrets-manager/instance/create/create_test.go b/internal/cmd/secrets-manager/instance/create/create_test.go index 92b04ba79..437da9492 100644 --- a/internal/cmd/secrets-manager/instance/create/create_test.go +++ b/internal/cmd/secrets-manager/instance/create/create_test.go @@ -4,13 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -22,6 +26,13 @@ var testClient = &secretsmanager.APIClient{} var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() +const ( + testKmsKeyId = "key-id" + testKmsKeyringId = "keyring-id" + testKmsKeyVersion = int64(1) + testKmsServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" +) + func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ projectIdFlag: testProjectId, @@ -76,6 +87,7 @@ func fixtureUpdateACLsRequest(mods ...func(request *secretsmanager.ApiUpdateACLs func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -158,6 +170,24 @@ func TestParseInput(t *testing.T) { *model.Acls = append(*model.Acls, "1.2.3.4/32") }), }, + { + description: "kms flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, aclFlag) + flagValues[kmsKeyIdFlag] = testKmsKeyId + flagValues[kmsKeyringIdFlag] = testKmsKeyringId + flagValues[kmsKeyVersionFlag] = "1" + flagValues[kmsServiceAccountEmailFlag] = testKmsServiceAccountEmail + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Acls = nil + model.KmsKeyId = utils.Ptr(testKmsKeyId) + model.KmsKeyringId = utils.Ptr(testKmsKeyringId) + model.KmsKeyVersion = utils.Ptr(testKmsKeyVersion) + model.KmsServiceAccountEmail = utils.Ptr(testKmsServiceAccountEmail) + }), + }, { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { @@ -183,56 +213,9 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + aclFlag: tt.aclValues, + }, tt.isValid) }) } } @@ -248,6 +231,28 @@ func TestBuildCreateInstanceRequest(t *testing.T) { model: fixtureInputModel(), expectedRequest: fixtureRequest(), }, + { + description: "with kms", + model: fixtureInputModel(func(model *inputModel) { + model.Acls = nil + model.KmsKeyId = utils.Ptr(testKmsKeyId) + model.KmsKeyringId = utils.Ptr(testKmsKeyringId) + model.KmsKeyVersion = utils.Ptr(testKmsKeyVersion) + model.KmsServiceAccountEmail = utils.Ptr(testKmsServiceAccountEmail) + }), + expectedRequest: fixtureRequest(func(request *secretsmanager.ApiCreateInstanceRequest) { + payload := secretsmanager.CreateInstancePayload{ + Name: utils.Ptr("example"), + KmsKey: &secretsmanager.KmsKeyPayload{ + KeyId: utils.Ptr(testKmsKeyId), + KeyRingId: utils.Ptr(testKmsKeyringId), + KeyVersion: utils.Ptr(testKmsKeyVersion), + ServiceAccountEmail: utils.Ptr(testKmsServiceAccountEmail), + }, + } + *request = (*request).CreateInstancePayload(payload) + }), + }, } for _, tt := range tests { @@ -329,7 +334,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.instanceId, tt.args.instance); (err != nil) != tt.wantErr { diff --git a/internal/cmd/secrets-manager/instance/delete/delete.go b/internal/cmd/secrets-manager/instance/delete/delete.go index 960034165..c4c2d1602 100644 --- a/internal/cmd/secrets-manager/instance/delete/delete.go +++ b/internal/cmd/secrets-manager/instance/delete/delete.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/client" secretsmanagerUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" ) const ( @@ -25,7 +28,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", instanceIdArg), Short: "Deletes a Secrets Manager instance", @@ -38,29 +41,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := secretsmanagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete instance %q? (This cannot be undone)", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -70,7 +71,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Secrets Manager instance: %w", err) } - p.Info("Deleted instance %q \n", model.InstanceId) + params.Printer.Info("Deleted instance %q \n", model.InstanceId) return nil }, } @@ -90,15 +91,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/secrets-manager/instance/delete/delete_test.go b/internal/cmd/secrets-manager/instance/delete/delete_test.go index b36eb87b8..6a1909548 100644 --- a/internal/cmd/secrets-manager/instance/delete/delete_test.go +++ b/internal/cmd/secrets-manager/instance/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -137,54 +137,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/secrets-manager/instance/describe/describe.go b/internal/cmd/secrets-manager/instance/describe/describe.go index 8551fa8bf..75c8cbd7c 100644 --- a/internal/cmd/secrets-manager/instance/describe/describe.go +++ b/internal/cmd/secrets-manager/instance/describe/describe.go @@ -2,11 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +29,7 @@ type inputModel struct { InstanceId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", instanceIdArg), Short: "Shows details of a Secrets Manager instance", @@ -45,12 +45,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -69,7 +69,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read Secrets Manager instance ACLs: %w", err) } - return outputResult(p, model.OutputFormat, instance, aclList) + return outputResult(params.Printer, model.OutputFormat, instance, aclList) }, } return cmd @@ -88,15 +88,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu InstanceId: instanceId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -122,24 +114,7 @@ func outputResult(p *print.Printer, outputFormat string, instance *secretsmanage *secretsmanager.ListACLsResponse }{instance, aclList} - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(output, "", " ") - if err != nil { - return fmt.Errorf("marshal Secrets Manager instance: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(output, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Secrets Manager instance: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, output, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(instance.Id)) table.AddSeparator() @@ -153,6 +128,17 @@ func outputResult(p *print.Printer, outputFormat string, instance *secretsmanage table.AddSeparator() table.AddRow("CREATION DATE", utils.PtrString(instance.CreationStartDate)) table.AddSeparator() + kmsKey := instance.KmsKey + showKms := kmsKey != nil && (kmsKey.KeyId != nil || kmsKey.KeyRingId != nil || kmsKey.KeyVersion != nil || kmsKey.ServiceAccountEmail != nil) + if showKms { + table.AddRow("KMS KEY ID", utils.PtrString(kmsKey.KeyId)) + table.AddSeparator() + table.AddRow("KMS KEYRING ID", utils.PtrString(kmsKey.KeyRingId)) + table.AddSeparator() + table.AddRow("KMS KEY VERSION", utils.PtrString(kmsKey.KeyVersion)) + table.AddSeparator() + table.AddRow("KMS SERVICE ACCOUNT EMAIL", utils.PtrString(kmsKey.ServiceAccountEmail)) + } // Only show ACL if it's present and not empty if aclList.Acls != nil && len(*aclList.Acls) > 0 { var cidrs []string @@ -161,6 +147,9 @@ func outputResult(p *print.Printer, outputFormat string, instance *secretsmanage cidrs = append(cidrs, *acl.Cidr) } + if showKms { + table.AddSeparator() + } table.AddRow("ACL", strings.Join(cidrs, ",")) } err := table.Display(p) @@ -169,5 +158,5 @@ func outputResult(p *print.Printer, outputFormat string, instance *secretsmanage } return nil - } + }) } diff --git a/internal/cmd/secrets-manager/instance/describe/describe_test.go b/internal/cmd/secrets-manager/instance/describe/describe_test.go index 212c825ab..fb36f10bb 100644 --- a/internal/cmd/secrets-manager/instance/describe/describe_test.go +++ b/internal/cmd/secrets-manager/instance/describe/describe_test.go @@ -4,8 +4,12 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -145,54 +149,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -291,9 +248,24 @@ func TestOutputResult(t *testing.T) { }, wantErr: false, }, + { + name: "instance with kms key", + args: args{ + instance: &secretsmanager.Instance{ + KmsKey: &secretsmanager.KmsKeyPayload{ + KeyId: utils.Ptr("key-id"), + KeyRingId: utils.Ptr("keyring-id"), + KeyVersion: utils.Ptr(int64(1)), + ServiceAccountEmail: utils.Ptr("my-service-account-1234567@sa.stackit.cloud"), + }, + }, + aclList: &secretsmanager.ListACLsResponse{}, + }, + wantErr: false, + }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instance, tt.args.aclList); (err != nil) != tt.wantErr { diff --git a/internal/cmd/secrets-manager/instance/instance.go b/internal/cmd/secrets-manager/instance/instance.go index cc38f6ca7..8edeb55fc 100644 --- a/internal/cmd/secrets-manager/instance/instance.go +++ b/internal/cmd/secrets-manager/instance/instance.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/instance/list" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/instance/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "instance", Short: "Provides functionality for Secrets Manager instances", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/secrets-manager/instance/list/list.go b/internal/cmd/secrets-manager/instance/list/list.go index 019aff8f6..a8f7b9391 100644 --- a/internal/cmd/secrets-manager/instance/list/list.go +++ b/internal/cmd/secrets-manager/instance/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all Secrets Manager instances", @@ -46,15 +47,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 Secrets Manager instances`, "$ stackit secrets-manager instance list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,12 +68,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Instances == nil || len(*resp.Instances) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No instances found for project %q\n", projectLabel) + params.Printer.Info("No instances found for project %q\n", projectLabel) return nil } instances := *resp.Instances @@ -82,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { instances = instances[:*model.Limit] } - return outputResult(p, model.OutputFormat, instances) + return outputResult(params.Printer, model.OutputFormat, instances) }, } @@ -94,7 +95,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -113,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -131,24 +124,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmana } func outputResult(p *print.Printer, outputFormat string, instances []secretsmanager.Instance) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(instances, "", " ") - if err != nil { - return fmt.Errorf("marshal Secrets Manager instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Secrets Manager instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, instances, func() error { table := tables.NewTable() table.SetHeader("ID", "NAME", "STATE", "SECRETS") for i := range instances { @@ -166,5 +142,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []secretsmana } return nil - } + }) } diff --git a/internal/cmd/secrets-manager/instance/list/list_test.go b/internal/cmd/secrets-manager/instance/list/list_test.go index 3903b0210..fa1cc496a 100644 --- a/internal/cmd/secrets-manager/instance/list/list_test.go +++ b/internal/cmd/secrets-manager/instance/list/list_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/secretsmanager" ) @@ -59,6 +61,7 @@ func fixtureRequest(mods ...func(request *secretsmanager.ApiListInstancesRequest func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -123,48 +126,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -228,7 +190,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instances); (err != nil) != tt.wantErr { diff --git a/internal/cmd/secrets-manager/instance/update/update.go b/internal/cmd/secrets-manager/instance/update/update.go index e013c1e73..0a066ec01 100644 --- a/internal/cmd/secrets-manager/instance/update/update.go +++ b/internal/cmd/secrets-manager/instance/update/update.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -22,62 +24,97 @@ import ( const ( instanceIdArg = "INSTANCE_ID" - aclFlag = "acl" + instanceNameFlag = "name" + aclFlag = "acl" + + kmsKeyIdFlag = "kms-key-id" + kmsKeyringIdFlag = "kms-keyring-id" + kmsKeyVersionFlag = "kms-key-version" + kmsServiceAccountEmailFlag = "kms-service-account-email" ) type inputModel struct { *globalflags.GlobalFlagModel InstanceId string - Acls *[]string + InstanceName *string + Acls *[]string + + KmsKeyId *string + KmsKeyringId *string + KmsKeyVersion *int64 + KmsServiceAccountEmail *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", instanceIdArg), Short: "Updates a Secrets Manager instance", Long: "Updates a Secrets Manager instance.", Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), Example: examples.Build( + examples.NewExample( + `Update the name of a Secrets Manager instance with ID "xxx"`, + "$ stackit secrets-manager instance update xxx --name my-new-name"), examples.NewExample( `Update the range of IPs allowed to access a Secrets Manager instance with ID "xxx"`, "$ stackit secrets-manager instance update xxx --acl 1.2.3.0/24"), + examples.NewExample( + `Update the name and ACLs of a Secrets Manager instance with ID "xxx"`, + "$ stackit secrets-manager instance update xxx --name my-new-name --acl 1.2.3.0/24"), + examples.NewExample( + `Update the KMS key settings of a Secrets Manager instance with ID "xxx"`, + "$ stackit secrets-manager instance update xxx --name my-instance --kms-key-id key-id --kms-keyring-id keyring-id --kms-key-version 1 --kms-service-account-email my-service-account-1234567@sa.stackit.cloud"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - instanceLabel, err := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + existingInstanceName, err := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) - instanceLabel = model.InstanceId + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) + existingInstanceName = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) + prompt := fmt.Sprintf("Are you sure you want to update instance %q?", existingInstanceName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API - execute UpdateInstance and/or UpdateACLs based on flags + if model.InstanceName != nil { + req := buildUpdateInstanceRequest(ctx, model, apiClient) + err = req.Execute() if err != nil { - return err + return fmt.Errorf("update Secrets Manager instance: %w", err) } } - // Call API - req := buildRequest(ctx, model, apiClient) - err = req.Execute() - if err != nil { - return fmt.Errorf("update Secrets Manager instance: %w", err) + if model.Acls != nil { + req := buildUpdateACLsRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + if model.InstanceName != nil { + return fmt.Errorf(`the Secrets Manager instance was successfully updated, but the configuration of the ACLs failed. + +If you want to retry configuring the ACLs, you can do it via: + $ stackit secrets-manager instance update %s --acl %s`, model.InstanceId, *model.Acls) + } + return fmt.Errorf("update Secrets Manager instance ACLs: %w", err) + } } - p.Info("Updated instance %q\n", instanceLabel) + params.Printer.Info("Updated instance %q\n", existingInstanceName) return nil }, } @@ -86,7 +123,16 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { + 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(kmsKeyIdFlag, "", "ID of the KMS key to use for encryption") + cmd.Flags().String(kmsKeyringIdFlag, "", "ID of the KMS key ring") + cmd.Flags().Int64(kmsKeyVersionFlag, 0, "Version of the KMS key") + cmd.Flags().String(kmsServiceAccountEmailFlag, "", "Service account email for KMS access") + + cmd.MarkFlagsRequiredTogether(kmsKeyIdFlag, kmsKeyringIdFlag, kmsKeyVersionFlag, kmsServiceAccountEmailFlag) + cmd.MarkFlagsOneRequired(aclFlag, instanceNameFlag) } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -97,31 +143,47 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu return nil, &cliErr.ProjectIdError{} } - acls := flags.FlagToStringSlicePointer(p, cmd, aclFlag) + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + InstanceName: flags.FlagToStringPointer(p, cmd, instanceNameFlag), + Acls: flags.FlagToStringSlicePointer(p, cmd, aclFlag), + KmsKeyId: flags.FlagToStringPointer(p, cmd, kmsKeyIdFlag), + KmsKeyringId: flags.FlagToStringPointer(p, cmd, kmsKeyringIdFlag), + KmsKeyVersion: flags.FlagToInt64Pointer(p, cmd, kmsKeyVersionFlag), + KmsServiceAccountEmail: flags.FlagToStringPointer(p, cmd, kmsServiceAccountEmailFlag), + } - if acls == nil { - return nil, &cliErr.EmptyUpdateError{} + if model.KmsKeyId != nil && model.InstanceName == nil { + return nil, fmt.Errorf("--name is required when using KMS flags") } - model := inputModel{ - GlobalFlagModel: globalFlags, - InstanceId: instanceId, - Acls: acls, + p.DebugInputModel(model) + return &model, nil +} + +func buildUpdateInstanceRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiUpdateInstanceRequest { + req := apiClient.UpdateInstance(ctx, model.ProjectId, model.InstanceId) + + payload := secretsmanager.UpdateInstancePayload{ + Name: model.InstanceName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + if model.KmsKeyId != nil { + payload.KmsKey = &secretsmanager.KmsKeyPayload{ + KeyId: model.KmsKeyId, + KeyRingId: model.KmsKeyringId, + KeyVersion: model.KmsKeyVersion, + ServiceAccountEmail: model.KmsServiceAccountEmail, } } - return &model, nil + req = req.UpdateInstancePayload(payload) + + return req } -func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiUpdateACLsRequest { +func buildUpdateACLsRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiUpdateACLsRequest { req := apiClient.UpdateACLs(ctx, model.ProjectId, model.InstanceId) cidrs := []secretsmanager.UpdateACLPayload{} diff --git a/internal/cmd/secrets-manager/instance/update/update_test.go b/internal/cmd/secrets-manager/instance/update/update_test.go index 8668d9ca1..02a2641b3 100644 --- a/internal/cmd/secrets-manager/instance/update/update_test.go +++ b/internal/cmd/secrets-manager/instance/update/update_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -31,6 +31,14 @@ var ( testInstanceId = uuid.NewString() ) +const ( + testInstanceName = "test-instance" + testKmsKeyId = "key-id" + testKmsKeyringId = "keyring-id" + testKmsKeyVersion = int64(1) + testKmsServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" +) + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testInstanceId, @@ -80,6 +88,24 @@ func fixtureRequest(mods ...func(request *secretsmanager.ApiUpdateACLsRequest)) return request } +func fixtureUpdateInstanceRequest(mods ...func(request *secretsmanager.ApiUpdateInstanceRequest)) secretsmanager.ApiUpdateInstanceRequest { + request := testClient.UpdateInstance(testCtx, testProjectId, testInstanceId) + request = request.UpdateInstancePayload(secretsmanager.UpdateInstancePayload{ + Name: utils.Ptr(testInstanceName), + KmsKey: &secretsmanager.KmsKeyPayload{ + KeyId: utils.Ptr(testKmsKeyId), + KeyRingId: utils.Ptr(testKmsKeyringId), + KeyVersion: utils.Ptr(testKmsKeyVersion), + ServiceAccountEmail: utils.Ptr(testKmsServiceAccountEmail), + }, + }) + + for _, mod := range mods { + mod(&request) + } + return request +} + func TestParseInput(t *testing.T) { tests := []struct { description string @@ -109,13 +135,7 @@ func TestParseInput(t *testing.T) { isValid: false, }, { - description: "no flag values", - argValues: fixtureArgValues(), - flagValues: map[string]string{}, - isValid: false, - }, - { - description: "required flags only (no values to update)", + description: "no update flags", argValues: fixtureArgValues(), flagValues: map[string]string{ projectIdFlag: testProjectId, @@ -170,6 +190,27 @@ func TestParseInput(t *testing.T) { flagValues: fixtureFlagValues(), isValid: false, }, + { + description: "kms key id without other required kms flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + kmsKeyIdFlag: "key-id", + }, + isValid: false, + }, + { + description: "kms flags without name flag", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + kmsKeyIdFlag: "key-id", + kmsKeyringIdFlag: "keyring-id", + kmsKeyVersionFlag: "1", + kmsServiceAccountEmailFlag: "my-service-account-1234567@sa.stackit.cloud", + }, + isValid: false, + }, { description: "repeated acl flags", argValues: fixtureArgValues(), @@ -193,72 +234,85 @@ func TestParseInput(t *testing.T) { ) }), }, + { + description: "name flag only", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: "updated-name", + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Acls = nil + model.InstanceName = utils.Ptr("updated-name") + }), + }, + { + description: "name and acl flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: testInstanceName, + aclFlag: testACL1, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.InstanceName = utils.Ptr(testInstanceName) + }), + }, + { + description: "kms flags with name", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: testInstanceName, + kmsKeyIdFlag: testKmsKeyId, + kmsKeyringIdFlag: testKmsKeyringId, + kmsKeyVersionFlag: "1", + kmsServiceAccountEmailFlag: testKmsServiceAccountEmail, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Acls = nil + model.InstanceName = utils.Ptr(testInstanceName) + model.KmsKeyId = utils.Ptr(testKmsKeyId) + model.KmsKeyringId = utils.Ptr(testKmsKeyringId) + model.KmsKeyVersion = utils.Ptr(testKmsKeyVersion) + model.KmsServiceAccountEmail = utils.Ptr(testKmsServiceAccountEmail) + }), + }, + { + description: "name, acl and kms flags together", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: testInstanceName, + aclFlag: testACL1, + kmsKeyIdFlag: testKmsKeyId, + kmsKeyringIdFlag: testKmsKeyringId, + kmsKeyVersionFlag: "1", + kmsServiceAccountEmailFlag: testKmsServiceAccountEmail, + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.InstanceName = utils.Ptr(testInstanceName) + model.KmsKeyId = utils.Ptr(testKmsKeyId) + model.KmsKeyringId = utils.Ptr(testKmsKeyringId) + model.KmsKeyVersion = utils.Ptr(testKmsKeyVersion) + model.KmsServiceAccountEmail = utils.Ptr(testKmsServiceAccountEmail) + }), + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInputWithAdditionalFlags(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, map[string][]string{ + aclFlag: tt.aclValues, + }, tt.isValid) }) } } -func TestBuildRequest(t *testing.T) { +func TestBuildUpdateACLsRequest(t *testing.T) { tests := []struct { description string model *inputModel @@ -284,7 +338,53 @@ func TestBuildRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request := buildRequest(testCtx, tt.model, testClient) + request := buildUpdateACLsRequest(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) + } + }) + } +} + +func TestBuildUpdateInstanceRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest secretsmanager.ApiUpdateInstanceRequest + }{ + { + description: "with name only", + model: fixtureInputModel(func(model *inputModel) { + model.Acls = nil + model.InstanceName = utils.Ptr(testInstanceName) + }), + expectedRequest: testClient.UpdateInstance(testCtx, testProjectId, testInstanceId). + UpdateInstancePayload(secretsmanager.UpdateInstancePayload{ + Name: utils.Ptr(testInstanceName), + }), + }, + { + description: "with KMS settings", + model: fixtureInputModel(func(model *inputModel) { + model.Acls = nil + model.InstanceName = utils.Ptr(testInstanceName) + model.KmsKeyId = utils.Ptr(testKmsKeyId) + model.KmsKeyringId = utils.Ptr(testKmsKeyringId) + model.KmsKeyVersion = utils.Ptr(testKmsKeyVersion) + model.KmsServiceAccountEmail = utils.Ptr(testKmsServiceAccountEmail) + }), + expectedRequest: fixtureUpdateInstanceRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildUpdateInstanceRequest(testCtx, tt.model, testClient) diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), diff --git a/internal/cmd/secrets-manager/secrets_manager.go b/internal/cmd/secrets-manager/secrets_manager.go index d9c78f035..eb5632c4f 100644 --- a/internal/cmd/secrets-manager/secrets_manager.go +++ b/internal/cmd/secrets-manager/secrets_manager.go @@ -4,13 +4,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "secrets-manager", Short: "Provides functionality for Secrets Manager", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(instance.NewCmd(p)) - cmd.AddCommand(user.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(instance.NewCmd(params)) + cmd.AddCommand(user.NewCmd(params)) } diff --git a/internal/cmd/secrets-manager/user/create/create.go b/internal/cmd/secrets-manager/user/create/create.go index 12a767cf4..1584b5cf5 100644 --- a/internal/cmd/secrets-manager/user/create/create.go +++ b/internal/cmd/secrets-manager/user/create/create.go @@ -2,10 +2,10 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -34,7 +34,7 @@ type inputModel struct { Write *bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Secrets Manager user", @@ -52,31 +52,29 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit secrets-manager user create --instance-id xxx --write"), ), Args: args.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -86,7 +84,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Secrets Manager user: %w", err) } - return outputResult(p, model.OutputFormat, instanceLabel, resp) + return outputResult(params.Printer, model.OutputFormat, instanceLabel, resp) }, } @@ -103,7 +101,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -116,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Write: utils.Ptr(flags.FlagToBoolValue(p, cmd, writeFlag)), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -142,24 +132,7 @@ func outputResult(p *print.Printer, outputFormat, instanceLabel string, user *se return fmt.Errorf("user is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal Secrets Manager user: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Secrets Manager user: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, user, func() error { p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, utils.PtrString(user.Id)) p.Outputf("Username: %s\n", utils.PtrString(user.Username)) p.Outputf("Password: %s\n", utils.PtrString(user.Password)) @@ -167,5 +140,5 @@ func outputResult(p *print.Printer, outputFormat, instanceLabel string, user *se p.Outputf("Write Access: %s\n", utils.PtrString(user.Write)) return nil - } + }) } diff --git a/internal/cmd/secrets-manager/user/create/create_test.go b/internal/cmd/secrets-manager/user/create/create_test.go index be5ae1a37..256a1f29a 100644 --- a/internal/cmd/secrets-manager/user/create/create_test.go +++ b/internal/cmd/secrets-manager/user/create/create_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/secretsmanager" ) @@ -69,6 +71,7 @@ func fixtureRequest(mods ...func(request *secretsmanager.ApiCreateUserRequest)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -152,48 +155,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -251,7 +213,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.instanceLabel, tt.args.user); (err != nil) != tt.wantErr { diff --git a/internal/cmd/secrets-manager/user/delete/delete.go b/internal/cmd/secrets-manager/user/delete/delete.go index 6ed848d0e..65193fac3 100644 --- a/internal/cmd/secrets-manager/user/delete/delete.go +++ b/internal/cmd/secrets-manager/user/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -31,7 +33,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", userIdArg), Short: "Deletes a Secrets Manager user", @@ -47,35 +49,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, utils.ValidateUUID), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } userLabel, err := secretsManagerUtils.GetUserLabel(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) if err != nil { - p.Debug(print.ErrorLevel, "get user label: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user label: %v", err) userLabel = fmt.Sprintf("%q", model.UserId) } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete user %s of instance %q? (This cannot be undone)", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete user %s of instance %q? (This cannot be undone)", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -85,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Secrets Manager user: %w", err) } - p.Info("Deleted user %s of instance %q\n", userLabel, instanceLabel) + params.Printer.Info("Deleted user %s of instance %q\n", userLabel, instanceLabel) return nil }, } @@ -114,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/secrets-manager/user/delete/delete_test.go b/internal/cmd/secrets-manager/user/delete/delete_test.go index 8ad0a2bf2..8b66aa96a 100644 --- a/internal/cmd/secrets-manager/user/delete/delete_test.go +++ b/internal/cmd/secrets-manager/user/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -152,54 +152,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/secrets-manager/user/describe/describe.go b/internal/cmd/secrets-manager/user/describe/describe.go index 2bfb18b08..5a658385c 100644 --- a/internal/cmd/secrets-manager/user/describe/describe.go +++ b/internal/cmd/secrets-manager/user/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +33,7 @@ type inputModel struct { UserId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", userIdArg), Short: "Shows details of a Secrets Manager user", @@ -49,13 +49,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, utils.ValidateUUID), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -67,7 +67,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get Secrets Manager user: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } @@ -96,15 +96,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -114,24 +106,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmana } func outputResult(p *print.Printer, outputFormat string, user secretsmanager.User) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(user, "", " ") - if err != nil { - return fmt.Errorf("marshal Secrets Manager user: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Secrets Manager user: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, user, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(user.Id)) table.AddSeparator() @@ -153,5 +128,5 @@ func outputResult(p *print.Printer, outputFormat string, user secretsmanager.Use } return nil - } + }) } diff --git a/internal/cmd/secrets-manager/user/describe/describe_test.go b/internal/cmd/secrets-manager/user/describe/describe_test.go index 46cd4b63e..c267fe143 100644 --- a/internal/cmd/secrets-manager/user/describe/describe_test.go +++ b/internal/cmd/secrets-manager/user/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -164,54 +167,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -268,7 +224,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.user); (err != nil) != tt.wantErr { diff --git a/internal/cmd/secrets-manager/user/list/list.go b/internal/cmd/secrets-manager/user/list/list.go index 19ff7f8ea..163c523c9 100644 --- a/internal/cmd/secrets-manager/user/list/list.go +++ b/internal/cmd/secrets-manager/user/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( secretsManagerUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all Secrets Manager users", @@ -49,15 +50,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 Secrets Manager users with ID "xxx"`, "$ stackit secrets-manager user list --instance-id xxx --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -71,10 +72,10 @@ func NewCmd(p *print.Printer) *cobra.Command { if resp.Users == nil || len(*resp.Users) == 0 { instanceLabel, err := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = *model.InstanceId } - p.Info("No users found for instance %q\n", instanceLabel) + params.Printer.Info("No users found for instance %q\n", instanceLabel) return nil } users := *resp.Users @@ -84,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { users = users[:*model.Limit] } - return outputResult(p, model.OutputFormat, users) + return outputResult(params.Printer, model.OutputFormat, users) }, } @@ -100,7 +101,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -120,15 +121,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -138,24 +131,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmana } func outputResult(p *print.Printer, outputFormat string, users []secretsmanager.User) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(users, "", " ") - if err != nil { - return fmt.Errorf("marshal Secrets Manager user list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(users, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Secrets Manager user list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, users, func() error { table := tables.NewTable() table.SetHeader("ID", "USERNAME", "DESCRIPTION", "WRITE ACCESS") for i := range users { @@ -173,5 +149,5 @@ func outputResult(p *print.Printer, outputFormat string, users []secretsmanager. } return nil - } + }) } diff --git a/internal/cmd/secrets-manager/user/list/list_test.go b/internal/cmd/secrets-manager/user/list/list_test.go index ac4c830a7..30ce25955 100644 --- a/internal/cmd/secrets-manager/user/list/list_test.go +++ b/internal/cmd/secrets-manager/user/list/list_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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/secretsmanager" ) @@ -62,6 +64,7 @@ func fixtureRequest(mods ...func(request *secretsmanager.ApiListUsersRequest)) s func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -130,48 +133,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -235,7 +197,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.users); (err != nil) != tt.wantErr { diff --git a/internal/cmd/secrets-manager/user/update/update.go b/internal/cmd/secrets-manager/user/update/update.go index 573076eb4..38507699b 100644 --- a/internal/cmd/secrets-manager/user/update/update.go +++ b/internal/cmd/secrets-manager/user/update/update.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -35,7 +37,7 @@ type inputModel struct { DisableWrite *bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", userIdArg), Short: "Updates the write privileges Secrets Manager user", @@ -51,35 +53,33 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(userIdArg, utils.ValidateUUID), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } instanceLabel, err := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) if err != nil { - p.Debug(print.ErrorLevel, "get instance name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get instance name: %v", err) instanceLabel = model.InstanceId } userLabel, err := secretsManagerUtils.GetUserLabel(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) if err != nil { - p.Debug(print.ErrorLevel, "get user label: %v", err) + params.Printer.Debug(print.ErrorLevel, "get user label: %v", err) userLabel = fmt.Sprintf("%q", model.UserId) } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update user %s of instance %q?", userLabel, instanceLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update user %s of instance %q?", userLabel, instanceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -93,7 +93,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update Secrets Manager user: %w", err) } - p.Info("Updated user %s of instance %q\n", userLabel, instanceLabel) + params.Printer.Info("Updated user %s of instance %q\n", userLabel, instanceLabel) return nil }, } @@ -136,15 +136,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UserId: userId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/secrets-manager/user/update/update_test.go b/internal/cmd/secrets-manager/user/update/update_test.go index b3b8a67b0..830bacdfd 100644 --- a/internal/cmd/secrets-manager/user/update/update_test.go +++ b/internal/cmd/secrets-manager/user/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -189,7 +191,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/secrets-manager/user/user.go b/internal/cmd/secrets-manager/user/user.go index 8dcd68410..6f14e07dd 100644 --- a/internal/cmd/secrets-manager/user/user.go +++ b/internal/cmd/secrets-manager/user/user.go @@ -2,10 +2,11 @@ package user import ( "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/create" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/describe" @@ -13,7 +14,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/update" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "user", Short: "Provides functionality for Secrets Manager users", @@ -21,14 +22,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/security-group/create/create.go b/internal/cmd/security-group/create/create.go index 24f3da09c..dfbbc258c 100644 --- a/internal/cmd/security-group/create/create.go +++ b/internal/cmd/security-group/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -33,7 +34,7 @@ type inputModel struct { Stateful *bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates security groups", @@ -43,25 +44,23 @@ func NewCmd(p *print.Printer) *cobra.Command { examples.NewExample(`Create a named group`, `$ stackit security-group create --name my-new-group`), examples.NewExample(`Create a named group with labels`, `$ stackit security-group create --name my-new-group --labels label1=value1,label2=value2`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create the security group %q?", *model.Name) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create the security group %q?", *model.Name) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -72,7 +71,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create security group: %w", err) } - if err := outputResult(p, model.OutputFormat, *model.Name, *group); err != nil { + if err := outputResult(params.Printer, model.OutputFormat, *model.Name, *group); err != nil { return err } @@ -95,7 +94,7 @@ func configureFlags(cmd *cobra.Command) { } } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -111,32 +110,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Stateful: flags.FlagToBoolPointer(p, cmd, statefulFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateSecurityGroupRequest { - request := apiClient.CreateSecurityGroup(ctx, model.ProjectId) - - var labelsMap *map[string]any - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } + request := apiClient.CreateSecurityGroup(ctx, model.ProjectId, model.Region) + payload := iaas.CreateSecurityGroupPayload{ Description: model.Description, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), Name: model.Name, Stateful: model.Stateful, } @@ -145,25 +128,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat, name string, resp iaas.SecurityGroup) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal security group: %w", err) - } - p.Outputln(string(details)) - + return p.OutputResult(outputFormat, resp, func() error { + p.Outputf("Created security group %q.\nSecurity Group ID %s\n", name, utils.PtrString(resp.Id)) return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal security group: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - p.Outputf("Created security group %q\n", name) - return nil - } + }) } diff --git a/internal/cmd/security-group/create/create_test.go b/internal/cmd/security-group/create/create_test.go index 6a6a02898..0a082ee15 100644 --- a/internal/cmd/security-group/create/create_test.go +++ b/internal/cmd/security-group/create/create_test.go @@ -4,16 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -33,7 +39,9 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + descriptionFlag: testDescription, labelsFlag: "fooKey=fooValue,barKey=barValue,bazKey=bazValue", statefulFlag: "true", @@ -47,11 +55,15 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, - Labels: &testLabels, - Description: &testDescription, - Name: &testName, - Stateful: &testStateful, + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Labels: &testLabels, + Description: &testDescription, + Name: &testName, + Stateful: &testStateful, } for _, mod := range mods { mod(model) @@ -70,7 +82,7 @@ func toStringAnyMapPtr(m map[string]string) map[string]any { return result } func fixtureRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRequest)) iaas.ApiCreateSecurityGroupRequest { - request := testClient.CreateSecurityGroup(testCtx, testProjectId) + request := testClient.CreateSecurityGroup(testCtx, testProjectId, testRegion) request = request.CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{ Description: &testDescription, @@ -88,6 +100,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRequest)) i func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -106,21 +119,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -167,44 +180,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -226,7 +202,7 @@ func TestBuildRequest(t *testing.T) { model.Labels = nil }), expectedRequest: fixtureRequest(func(request *iaas.ApiCreateSecurityGroupRequest) { - *request = request.CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{ + *request = (*request).CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{ Description: &testDescription, Labels: nil, Name: &testName, @@ -240,7 +216,7 @@ func TestBuildRequest(t *testing.T) { model.Stateful = utils.Ptr(false) }), expectedRequest: fixtureRequest(func(request *iaas.ApiCreateSecurityGroupRequest) { - *request = request.CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{ + *request = (*request).CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{ Description: &testDescription, Labels: utils.Ptr(toStringAnyMapPtr(testLabels)), Name: &testName, @@ -282,7 +258,7 @@ func TestOutputResult(t *testing.T) { } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.name, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/security-group/delete/delete.go b/internal/cmd/security-group/delete/delete.go index 85781ae13..da840337c 100644 --- a/internal/cmd/security-group/delete/delete.go +++ b/internal/cmd/security-group/delete/delete.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { @@ -24,7 +27,7 @@ type inputModel struct { const groupIdArg = "GROUP_ID" -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", groupIdArg), Short: "Deletes a security group", @@ -35,35 +38,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - groupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.SecurityGroupId) + groupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId) if err != nil { - p.Warn("get security group name: %v", err) + params.Printer.Warn("get security group name: %v", err) groupLabel = model.SecurityGroupId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete the security group %q for %q?", groupLabel, projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete the security group %q for %q?", groupLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -72,7 +73,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if err := request.Execute(); err != nil { return fmt.Errorf("delete security group: %w", err) } - p.Info("Deleted security group %q for %q\n", groupLabel, projectLabel) + params.Printer.Info("Deleted security group %q for %q\n", groupLabel, projectLabel) return nil }, @@ -92,19 +93,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM SecurityGroupId: cliArgs[0], } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteSecurityGroupRequest { - request := apiClient.DeleteSecurityGroup(ctx, model.ProjectId, model.SecurityGroupId) + request := apiClient.DeleteSecurityGroup(ctx, model.ProjectId, model.Region, model.SecurityGroupId) return request } diff --git a/internal/cmd/security-group/delete/delete_test.go b/internal/cmd/security-group/delete/delete_test.go index 7666e1585..0416a41fe 100644 --- a/internal/cmd/security-group/delete/delete_test.go +++ b/internal/cmd/security-group/delete/delete_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -13,7 +15,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -26,7 +30,8 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -36,7 +41,11 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, SecurityGroupId: testGroupId, } for _, mod := range mods { @@ -46,7 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteSecurityGroupRequest)) iaas.ApiDeleteSecurityGroupRequest { - request := testClient.DeleteSecurityGroup(testCtx, testProjectId, testGroupId) + request := testClient.DeleteSecurityGroup(testCtx, testProjectId, testRegion, testGroupId) for _, mod := range mods { mod(&request) } @@ -56,6 +65,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiDeleteSecurityGroupRequest)) i func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string args []string isValid bool @@ -71,14 +81,14 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -105,7 +115,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/security-group/describe/describe.go b/internal/cmd/security-group/describe/describe.go index 01d86df03..485c2f10d 100644 --- a/internal/cmd/security-group/describe/describe.go +++ b/internal/cmd/security-group/describe/describe.go @@ -2,10 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -27,7 +27,7 @@ type inputModel struct { const groupIdArg = "GROUP_ID" -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", groupIdArg), Short: "Describes security groups", @@ -38,13 +38,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -57,7 +57,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("get security group: %w", err) } - if err := outputResult(p, model.OutputFormat, group); err != nil { + if err := outputResult(params.Printer, model.OutputFormat, group); err != nil { return err } @@ -69,7 +69,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetSecurityGroupRequest { - request := apiClient.GetSecurityGroup(ctx, model.ProjectId, model.SecurityGroupId) + request := apiClient.GetSecurityGroup(ctx, model.ProjectId, model.Region, model.SecurityGroupId) return request } @@ -84,15 +84,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM SecurityGroupId: cliArgs[0], } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -100,24 +92,7 @@ func outputResult(p *print.Printer, outputFormat string, resp *iaas.SecurityGrou if resp == nil { return fmt.Errorf("security group response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal security group: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal security group: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { var content []tables.Table table := tables.NewTable() @@ -216,5 +191,5 @@ func outputResult(p *print.Printer, outputFormat string, resp *iaas.SecurityGrou } return nil - } + }) } diff --git a/internal/cmd/security-group/describe/describe_test.go b/internal/cmd/security-group/describe/describe_test.go index 6deaf042f..84c928b0e 100644 --- a/internal/cmd/security-group/describe/describe_test.go +++ b/internal/cmd/security-group/describe/describe_test.go @@ -4,15 +4,20 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -25,7 +30,8 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -35,7 +41,11 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, SecurityGroupId: testSecurityGroupId[0], } for _, mod := range mods { @@ -45,7 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetSecurityGroupRequest)) iaas.ApiGetSecurityGroupRequest { - request := testClient.GetSecurityGroup(testCtx, testProjectId, testSecurityGroupId[0]) + request := testClient.GetSecurityGroup(testCtx, testProjectId, testRegion, testSecurityGroupId[0]) for _, mod := range mods { mod(&request) } @@ -55,6 +65,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiGetSecurityGroupRequest)) iaas func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool args []string @@ -76,7 +87,7 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), args: testSecurityGroupId, isValid: false, @@ -84,7 +95,7 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), args: testSecurityGroupId, isValid: false, @@ -92,7 +103,7 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), args: testSecurityGroupId, isValid: false, @@ -119,7 +130,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) if err := globalflags.Configure(cmd.Flags()); err != nil { t.Errorf("cannot configure global flags: %v", err) } @@ -216,7 +227,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/security-group/list/list.go b/internal/cmd/security-group/list/list.go index 48caeddb3..d3788ee9c 100644 --- a/internal/cmd/security-group/list/list.go +++ b/internal/cmd/security-group/list/list.go @@ -2,12 +2,14 @@ package list import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -18,7 +20,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { @@ -30,7 +31,7 @@ const ( labelSelectorFlag = "label-selector" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists security groups", @@ -40,22 +41,22 @@ func NewCmd(p *print.Printer) *cobra.Command { examples.NewExample(`List all groups`, `$ stackit security-group list`), examples.NewExample(`List groups with labels`, `$ stackit security-group list --label-selector label1=value1,label2=value2`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } @@ -68,9 +69,9 @@ func NewCmd(p *print.Printer) *cobra.Command { } if items := response.GetItems(); len(items) == 0 { - p.Info("No security groups found for project %q", projectLabel) + params.Printer.Info("No security groups found for project %q", projectLabel) } else { - if err := outputResult(p, model.OutputFormat, items); err != nil { + if err := outputResult(params.Printer, model.OutputFormat, items); err != nil { return fmt.Errorf("output security groups: %w", err) } } @@ -87,7 +88,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(labelSelectorFlag, "", "Filter by label") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -98,20 +99,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListSecurityGroupsRequest { - request := apiClient.ListSecurityGroups(ctx, model.ProjectId) + request := apiClient.ListSecurityGroups(ctx, model.ProjectId, model.Region) if model.LabelSelector != nil { request = request.LabelSelector(*model.LabelSelector) } @@ -119,24 +112,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli return request } func outputResult(p *print.Printer, outputFormat string, items []iaas.SecurityGroup) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(items, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(items, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex instance list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, items, func() error { table := tables.NewTable() table.SetHeader("ID", "NAME", "STATEFUL", "DESCRIPTION", "LABELS") for _, item := range items { @@ -163,5 +139,5 @@ func outputResult(p *print.Printer, outputFormat string, items []iaas.SecurityGr } return nil - } + }) } diff --git a/internal/cmd/security-group/list/list_test.go b/internal/cmd/security-group/list/list_test.go index d19adf3a0..18cfa967e 100644 --- a/internal/cmd/security-group/list/list_test.go +++ b/internal/cmd/security-group/list/list_test.go @@ -4,16 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -26,7 +32,9 @@ var ( func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + labelSelectorFlag: testLabels, } for _, mod := range mods { @@ -37,8 +45,12 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, - LabelSelector: utils.Ptr(testLabels), + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + LabelSelector: utils.Ptr(testLabels), } for _, mod := range mods { mod(model) @@ -47,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListSecurityGroupsRequest)) iaas.ApiListSecurityGroupsRequest { - request := testClient.ListSecurityGroups(testCtx, testProjectId) + request := testClient.ListSecurityGroups(testCtx, testProjectId, testRegion) request = request.LabelSelector(testLabels) for _, mod := range mods { mod(&request) @@ -58,6 +70,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListSecurityGroupsRequest)) ia func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -76,21 +89,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -118,44 +131,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - if err := globalflags.Configure(cmd.Flags()); err != nil { - t.Errorf("cannot 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 err := cmd.ValidateRequiredFlags(); err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -177,7 +153,7 @@ func TestBuildRequest(t *testing.T) { model.LabelSelector = utils.Ptr("") }), expectedRequest: fixtureRequest(func(request *iaas.ApiListSecurityGroupsRequest) { - *request = request.LabelSelector("") + *request = (*request).LabelSelector("") }), }, { @@ -186,7 +162,7 @@ func TestBuildRequest(t *testing.T) { model.LabelSelector = utils.Ptr("foo=bar") }), expectedRequest: fixtureRequest(func(request *iaas.ApiListSecurityGroupsRequest) { - *request = request.LabelSelector("foo=bar") + *request = (*request).LabelSelector("foo=bar") }), }, } @@ -222,7 +198,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.items); (err != nil) != tt.wantErr { diff --git a/internal/cmd/security-group/rule/create/create.go b/internal/cmd/security-group/rule/create/create.go index a330e98eb..9b836dd81 100644 --- a/internal/cmd/security-group/rule/create/create.go +++ b/internal/cmd/security-group/rule/create/create.go @@ -2,10 +2,12 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -52,7 +53,7 @@ type inputModel struct { ProtocolName *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a security group rule", @@ -76,37 +77,35 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit security-group rule create --security-group-id xxx --direction ingress --protocol-number 1`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - securityGroupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.SecurityGroupId) + securityGroupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId) if err != nil { - p.Debug(print.ErrorLevel, "get security group name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get security group name: %v", err) securityGroupLabel = model.SecurityGroupId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a security group rule for security group %q for project %q?", securityGroupLabel, projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a security group rule for security group %q for project %q?", securityGroupLabel, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -116,7 +115,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create security group rule : %w", err) } - return outputResult(p, model, projectLabel, securityGroupLabel, resp) + return outputResult(params.Printer, model, projectLabel, securityGroupLabel, resp) }, } configureFlags(cmd) @@ -142,7 +141,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -164,20 +163,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ProtocolName: flags.FlagToStringPointer(p, cmd, protocolNameFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateSecurityGroupRuleRequest { - req := apiClient.CreateSecurityGroupRule(ctx, model.ProjectId, model.SecurityGroupId) + req := apiClient.CreateSecurityGroupRule(ctx, model.ProjectId, model.Region, model.SecurityGroupId) icmpParameters := &iaas.ICMPParameters{} portRange := &iaas.PortRange{} protocol := &iaas.CreateProtocol{} @@ -222,29 +213,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel, securityGro if securityGroupRule == nil { return fmt.Errorf("security group rule is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(securityGroupRule, "", " ") - if err != nil { - return fmt.Errorf("marshal security group rule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(securityGroupRule, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal security group rule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, securityGroupRule, func() error { operationState := "Created" if model.Async { operationState = "Triggered creation of" } p.Outputf("%s security group rule for security group %q in project %q.\nSecurity group rule ID: %s\n", operationState, securityGroupName, projectLabel, utils.PtrString(securityGroupRule.Id)) return nil - } + }) } diff --git a/internal/cmd/security-group/rule/create/create_test.go b/internal/cmd/security-group/rule/create/create_test.go index 1a0024129..02a29fe95 100644 --- a/internal/cmd/security-group/rule/create/create_test.go +++ b/internal/cmd/security-group/rule/create/create_test.go @@ -4,16 +4,23 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -26,7 +33,9 @@ var testRemoteSecurityGroupId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + securityGroupIdFlag: testSecurityGroupId, directionFlag: "ingress", descriptionFlag: "example-description", @@ -50,6 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, SecurityGroupId: testSecurityGroupId, @@ -72,7 +82,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRuleRequest)) iaas.ApiCreateSecurityGroupRuleRequest { - request := testClient.CreateSecurityGroupRule(testCtx, testProjectId, testSecurityGroupId) + request := testClient.CreateSecurityGroupRule(testCtx, testProjectId, testRegion, testSecurityGroupId) request = request.CreateSecurityGroupRulePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -81,7 +91,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRuleRequest } func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRuleRequest)) iaas.ApiCreateSecurityGroupRuleRequest { - request := testClient.CreateSecurityGroupRule(testCtx, testProjectId, testSecurityGroupId) + request := testClient.CreateSecurityGroupRule(testCtx, testProjectId, testRegion, testSecurityGroupId) request = request.CreateSecurityGroupRulePayload(iaas.CreateSecurityGroupRulePayload{ Direction: utils.Ptr("ingress"), }) @@ -120,6 +130,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateSecurityGroupRulePayload)) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -199,21 +210,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -242,53 +253,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.ValidateFlagGroups() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flag groups: %v", err) - } - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -309,6 +274,7 @@ func TestBuildRequest(t *testing.T) { model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, Direction: utils.Ptr("ingress"), @@ -360,7 +326,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.securityGroupName, tt.args.securityGroupRule); (err != nil) != tt.wantErr { diff --git a/internal/cmd/security-group/rule/delete/delete.go b/internal/cmd/security-group/rule/delete/delete.go index 247caed8a..837a94b9c 100644 --- a/internal/cmd/security-group/rule/delete/delete.go +++ b/internal/cmd/security-group/rule/delete/delete.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -26,10 +29,10 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel SecurityGroupRuleId string - SecurityGroupId *string + SecurityGroupId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", securityGroupRuleIdArg), Short: "Deletes a security group rule", @@ -46,35 +49,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - securityGroupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, *model.SecurityGroupId) + securityGroupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId) if err != nil { - p.Debug(print.ErrorLevel, "get security group name: %v", err) - securityGroupLabel = *model.SecurityGroupId + params.Printer.Debug(print.ErrorLevel, "get security group name: %v", err) + securityGroupLabel = model.SecurityGroupId } - securityGroupRuleLabel, err := iaasUtils.GetSecurityGroupRuleName(ctx, apiClient, model.ProjectId, model.SecurityGroupRuleId, *model.SecurityGroupId) + securityGroupRuleLabel, err := iaasUtils.GetSecurityGroupRuleName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupRuleId, model.SecurityGroupId) if err != nil { - p.Debug(print.ErrorLevel, "get security group rule name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get security group rule name: %v", err) securityGroupRuleLabel = model.SecurityGroupRuleId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete security group rule %q from security group %q?", securityGroupRuleLabel, securityGroupLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete security group rule %q from security group %q?", securityGroupRuleLabel, securityGroupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete security group rule: %w", err) } - p.Info("Deleted security group rule %q from security group %q\n", securityGroupRuleLabel, securityGroupLabel) + params.Printer.Info("Deleted security group rule %q from security group %q\n", securityGroupRuleLabel, securityGroupLabel) return nil }, } @@ -110,21 +111,13 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, SecurityGroupRuleId: securityGroupRuleId, - SecurityGroupId: flags.FlagToStringPointer(p, cmd, securityGroupIdFlag), - } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + SecurityGroupId: flags.FlagToStringValue(p, cmd, securityGroupIdFlag), } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteSecurityGroupRuleRequest { - return apiClient.DeleteSecurityGroupRule(ctx, model.ProjectId, *model.SecurityGroupId, model.SecurityGroupRuleId) + return apiClient.DeleteSecurityGroupRule(ctx, model.ProjectId, model.Region, model.SecurityGroupId, model.SecurityGroupRuleId) } diff --git a/internal/cmd/security-group/rule/delete/delete_test.go b/internal/cmd/security-group/rule/delete/delete_test.go index e8d36d7f6..73ae4e466 100644 --- a/internal/cmd/security-group/rule/delete/delete_test.go +++ b/internal/cmd/security-group/rule/delete/delete_test.go @@ -4,17 +4,20 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -37,7 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + securityGroupIdFlag: testSecurityGroupId, } for _, mod := range mods { @@ -50,9 +55,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, - SecurityGroupId: utils.Ptr(testSecurityGroupId), + SecurityGroupId: testSecurityGroupId, SecurityGroupRuleId: testSecurityGroupRuleId, } for _, mod := range mods { @@ -62,7 +68,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteSecurityGroupRuleRequest)) iaas.ApiDeleteSecurityGroupRuleRequest { - request := testClient.DeleteSecurityGroupRule(testCtx, testProjectId, testSecurityGroupId, testSecurityGroupRuleId) + request := testClient.DeleteSecurityGroupRule(testCtx, testProjectId, testRegion, testSecurityGroupId, testSecurityGroupRuleId) for _, mod := range mods { mod(&request) } @@ -94,7 +100,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -102,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -110,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -155,7 +161,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/security-group/rule/describe/describe.go b/internal/cmd/security-group/rule/describe/describe.go index 8e8bd0508..c4e421fa6 100644 --- a/internal/cmd/security-group/rule/describe/describe.go +++ b/internal/cmd/security-group/rule/describe/describe.go @@ -2,10 +2,12 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -29,10 +30,10 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel SecurityGroupRuleId string - SecurityGroupId *string + SecurityGroupId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", securityGroupRuleIdArg), Short: "Shows details of a security group rule", @@ -50,13 +51,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -68,7 +69,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read security group rule: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } configureFlags(cmd) @@ -93,47 +94,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, SecurityGroupRuleId: securityGroupRuleId, - SecurityGroupId: flags.FlagToStringPointer(p, cmd, securityGroupIdFlag), - } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + SecurityGroupId: flags.FlagToStringValue(p, cmd, securityGroupIdFlag), } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetSecurityGroupRuleRequest { - return apiClient.GetSecurityGroupRule(ctx, model.ProjectId, *model.SecurityGroupId, model.SecurityGroupRuleId) + return apiClient.GetSecurityGroupRule(ctx, model.ProjectId, model.Region, model.SecurityGroupId, model.SecurityGroupRuleId) } func outputResult(p *print.Printer, outputFormat string, securityGroupRule *iaas.SecurityGroupRule) error { if securityGroupRule == nil { return fmt.Errorf("security group rule is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(securityGroupRule, "", " ") - if err != nil { - return fmt.Errorf("marshal security group rule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(securityGroupRule, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal security group rule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, securityGroupRule, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(securityGroupRule.Id)) table.AddSeparator() @@ -185,5 +161,5 @@ func outputResult(p *print.Printer, outputFormat string, securityGroupRule *iaas return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/security-group/rule/describe/describe_test.go b/internal/cmd/security-group/rule/describe/describe_test.go index 03060fcb2..924b0dced 100644 --- a/internal/cmd/security-group/rule/describe/describe_test.go +++ b/internal/cmd/security-group/rule/describe/describe_test.go @@ -4,16 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -35,7 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + securityGroupIdFlag: testSecurityGroupId, } for _, mod := range mods { @@ -48,9 +55,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, - SecurityGroupId: utils.Ptr(testSecurityGroupId), + SecurityGroupId: testSecurityGroupId, SecurityGroupRuleId: testSecurityGroupRuleId, } for _, mod := range mods { @@ -60,7 +68,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetSecurityGroupRuleRequest)) iaas.ApiGetSecurityGroupRuleRequest { - request := testClient.GetSecurityGroupRule(testCtx, testProjectId, testSecurityGroupId, testSecurityGroupRuleId) + request := testClient.GetSecurityGroupRule(testCtx, testProjectId, testRegion, testSecurityGroupId, testSecurityGroupRuleId) for _, mod := range mods { mod(&request) } @@ -104,7 +112,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +120,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +128,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -164,54 +172,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -268,7 +229,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.securityGroupRule); (err != nil) != tt.wantErr { diff --git a/internal/cmd/security-group/rule/list/list.go b/internal/cmd/security-group/rule/list/list.go index 84c83ece4..1d39e5ed8 100644 --- a/internal/cmd/security-group/rule/list/list.go +++ b/internal/cmd/security-group/rule/list/list.go @@ -2,9 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -31,10 +31,10 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel Limit *int64 - SecurityGroupId *string + SecurityGroupId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all security group rules in a security group of a project", @@ -54,15 +54,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit security-group rule list --security-group-id xxx --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -75,18 +75,18 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - securityGroupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, *model.SecurityGroupId) + securityGroupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId) if err != nil { - p.Debug(print.ErrorLevel, "get security group name: %v", err) - securityGroupLabel = *model.SecurityGroupId + params.Printer.Debug(print.ErrorLevel, "get security group name: %v", err) + securityGroupLabel = model.SecurityGroupId } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No rules found in security group %q for project %q\n", securityGroupLabel, projectLabel) + params.Printer.Info("No rules found in security group %q for project %q\n", securityGroupLabel, projectLabel) return nil } @@ -96,7 +96,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -111,7 +111,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -128,44 +128,19 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { model := inputModel{ GlobalFlagModel: globalFlags, Limit: limit, - SecurityGroupId: flags.FlagToStringPointer(p, cmd, securityGroupIdFlag), - } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + SecurityGroupId: flags.FlagToStringValue(p, cmd, securityGroupIdFlag), } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListSecurityGroupRulesRequest { - return apiClient.ListSecurityGroupRules(ctx, model.ProjectId, *model.SecurityGroupId) + return apiClient.ListSecurityGroupRules(ctx, model.ProjectId, model.Region, model.SecurityGroupId) } func outputResult(p *print.Printer, outputFormat string, securityGroupRules []iaas.SecurityGroupRule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(securityGroupRules, "", " ") - if err != nil { - return fmt.Errorf("marshal security group rules: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(securityGroupRules, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal security group rules: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, securityGroupRules, func() error { table := tables.NewTable() table.SetHeader("ID", "ETHER TYPE", "DIRECTION", "PROTOCOL", "REMOTE SECURITY GROUP ID") @@ -191,5 +166,5 @@ func outputResult(p *print.Printer, outputFormat string, securityGroupRules []ia p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/security-group/rule/list/list_test.go b/internal/cmd/security-group/rule/list/list_test.go index f72f89897..b617a1b80 100644 --- a/internal/cmd/security-group/rule/list/list_test.go +++ b/internal/cmd/security-group/rule/list/list_test.go @@ -4,16 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -24,7 +30,9 @@ var testSecurityGroupId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", securityGroupIdFlag: testSecurityGroupId, } @@ -39,9 +47,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), - SecurityGroupId: utils.Ptr(testSecurityGroupId), + SecurityGroupId: testSecurityGroupId, } for _, mod := range mods { mod(model) @@ -50,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListSecurityGroupRulesRequest)) iaas.ApiListSecurityGroupRulesRequest { - request := testClient.ListSecurityGroupRules(testCtx, testProjectId, testSecurityGroupId) + request := testClient.ListSecurityGroupRules(testCtx, testProjectId, testRegion, testSecurityGroupId) for _, mod := range mods { mod(&request) } @@ -60,6 +69,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListSecurityGroupRulesRequest) func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -83,21 +93,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -140,46 +150,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -229,7 +200,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.securityGroupRules); (err != nil) != tt.wantErr { diff --git a/internal/cmd/security-group/rule/security_group_rule.go b/internal/cmd/security-group/rule/security_group_rule.go index c1e133841..fda58dd87 100644 --- a/internal/cmd/security-group/rule/security_group_rule.go +++ b/internal/cmd/security-group/rule/security_group_rule.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/rule/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/rule/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "rule", Short: "Provides functionality for security group rules", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/security-group/security_group.go b/internal/cmd/security-group/security_group.go index f20679d57..ef613d054 100644 --- a/internal/cmd/security-group/security_group.go +++ b/internal/cmd/security-group/security_group.go @@ -8,14 +8,14 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/rule" "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "security-group", Short: "Manage security groups", @@ -23,17 +23,17 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { cmd.AddCommand( - rule.NewCmd(p), - create.NewCmd(p), - delete.NewCmd(p), - describe.NewCmd(p), - list.NewCmd(p), - update.NewCmd(p), + rule.NewCmd(params), + create.NewCmd(params), + delete.NewCmd(params), + describe.NewCmd(params), + list.NewCmd(params), + update.NewCmd(params), ) } diff --git a/internal/cmd/security-group/update/update.go b/internal/cmd/security-group/update/update.go index ddff90e23..d73590631 100644 --- a/internal/cmd/security-group/update/update.go +++ b/internal/cmd/security-group/update/update.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) type inputModel struct { @@ -34,7 +37,7 @@ const ( labelsArg = "labels" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", groupNameArg), Short: "Updates a security group", @@ -46,35 +49,33 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - groupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.SecurityGroupId) + groupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId) if err != nil { - p.Warn("cannot retrieve groupname: %v", err) + params.Printer.Warn("cannot retrieve groupname: %v", err) groupLabel = model.SecurityGroupId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update the security group %q?", groupLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update the security group %q?", groupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("update security group: %w", err) } - p.Info("Updated security group \"%v\" for %q\n", utils.PtrString(resp.Name), projectLabel) + params.Printer.Info("Updated security group \"%v\" for %q\n", utils.PtrString(resp.Name), projectLabel) return nil }, @@ -118,31 +119,15 @@ func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputM return nil, fmt.Errorf("no flags have been passed") } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateSecurityGroupRequest { - request := apiClient.UpdateSecurityGroup(ctx, model.ProjectId, model.SecurityGroupId) + request := apiClient.UpdateSecurityGroup(ctx, model.ProjectId, model.Region, model.SecurityGroupId) payload := iaas.NewUpdateSecurityGroupPayload() payload.Description = model.Description - var labelsMap *map[string]any - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } - payload.Labels = labelsMap + payload.Labels = utils.ConvertStringMapToInterfaceMap(model.Labels) payload.Name = model.Name request = request.UpdateSecurityGroupPayload(*payload) diff --git a/internal/cmd/security-group/update/update_test.go b/internal/cmd/security-group/update/update_test.go index f27cbfc25..115cdb32c 100644 --- a/internal/cmd/security-group/update/update_test.go +++ b/internal/cmd/security-group/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -45,7 +49,9 @@ func toStringAnyMapPtr(m map[string]string) map[string]any { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + descriptionArg: testDescription, labelsArg: "fooKey=fooValue,barKey=barValue,bazKey=bazValue", nameArg: testName, @@ -58,7 +64,11 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault}, + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, Labels: &testLabels, Description: &testDescription, Name: &testName, @@ -71,7 +81,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUpdateSecurityGroupRequest)) iaas.ApiUpdateSecurityGroupRequest { - request := testClient.UpdateSecurityGroup(testCtx, testProjectId, testGroupId[0]) + request := testClient.UpdateSecurityGroup(testCtx, testProjectId, testRegion, testGroupId[0]) request = request.UpdateSecurityGroupPayload(iaas.UpdateSecurityGroupPayload{ Description: &testDescription, Labels: utils.Ptr(toStringAnyMapPtr(testLabels)), @@ -86,6 +96,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiUpdateSecurityGroupRequest)) i func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string args []string isValid bool @@ -101,7 +112,7 @@ func TestParseInput(t *testing.T) { { description: "no values but valid group id", flagValues: map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, }, args: testGroupId, isValid: false, @@ -114,7 +125,7 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), args: testGroupId, isValid: false, @@ -122,7 +133,7 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), args: testGroupId, isValid: false, @@ -130,7 +141,7 @@ func TestParseInput(t *testing.T) { { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), args: testGroupId, isValid: false, @@ -204,7 +215,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) if err := globalflags.Configure(cmd.Flags()); err != nil { t.Errorf("cannot configure global flags: %v", err) } @@ -267,7 +278,7 @@ func TestBuildRequest(t *testing.T) { model.Labels = nil }), expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateSecurityGroupRequest) { - *request = request.UpdateSecurityGroupPayload(iaas.UpdateSecurityGroupPayload{ + *request = (*request).UpdateSecurityGroupPayload(iaas.UpdateSecurityGroupPayload{ Description: &testDescription, Labels: nil, Name: &testName, diff --git a/internal/cmd/server/backup/backup.go b/internal/cmd/server/backup/backup.go index 8936ae65f..77d290a42 100644 --- a/internal/cmd/server/backup/backup.go +++ b/internal/cmd/server/backup/backup.go @@ -11,13 +11,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/schedule" volumebackup "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/volume-backup" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "backup", Short: "Provides functionality for server backups", @@ -25,18 +25,18 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(enable.NewCmd(p)) - cmd.AddCommand(disable.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(schedule.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(restore.NewCmd(p)) - cmd.AddCommand(del.NewCmd(p)) - cmd.AddCommand(volumebackup.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(enable.NewCmd(params)) + cmd.AddCommand(disable.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(schedule.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(restore.NewCmd(params)) + cmd.AddCommand(del.NewCmd(params)) + cmd.AddCommand(volumebackup.NewCmd(params)) } diff --git a/internal/cmd/server/backup/create/create.go b/internal/cmd/server/backup/create/create.go index 28243cdc8..50b36dce4 100644 --- a/internal/cmd/server/backup/create/create.go +++ b/internal/cmd/server/backup/create/create.go @@ -2,17 +2,18 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverbackup/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -39,7 +40,7 @@ type inputModel struct { BackupVolumeIds []string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Server Backup.", @@ -53,37 +54,35 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a Server Backup with name "mybackup" and retention period of 5 days`, `$ stackit server backup create --server-id xxx --name=mybackup --retention-period=5`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a Backup for server %s?", model.ServerId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a Backup for server %s?", model.ServerId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -96,7 +95,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Server Backup: %w", err) } - return outputResult(p, model.OutputFormat, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp) }, } configureFlags(cmd) @@ -113,7 +112,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -127,15 +126,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { BackupVolumeIds: flags.FlagToStringSliceValue(p, cmd, backupVolumeIdsFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -154,25 +145,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbacku } func outputResult(p *print.Printer, outputFormat, serverLabel string, resp serverbackup.BackupJob) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal server backup: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server backup: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Triggered creation of server backup for server %s. Backup ID: %s\n", serverLabel, utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/server/backup/create/create_test.go b/internal/cmd/server/backup/create/create_test.go index b0d547f44..07d305320 100644 --- a/internal/cmd/server/backup/create/create_test.go +++ b/internal/cmd/server/backup/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -81,6 +84,7 @@ func fixturePayload(mods ...func(payload *serverbackup.CreateBackupPayload)) ser func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -130,46 +134,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -228,7 +193,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/backup/delete/delete.go b/internal/cmd/server/backup/delete/delete.go index 7805d20c0..5eedcde2d 100644 --- a/internal/cmd/server/backup/delete/delete.go +++ b/internal/cmd/server/backup/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", backupIdArg), Short: "Deletes a Server Backup.", @@ -41,23 +43,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete server backup %q? (This cannot be undone)", model.BackupId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete server backup %q? (This cannot be undone)", model.BackupId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -67,7 +67,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Server Backup: %w", err) } - p.Info("Triggered deletion of server backup %q\n", model.BackupId) + params.Printer.Info("Triggered deletion of server backup %q\n", model.BackupId) return nil }, } @@ -96,15 +96,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/backup/delete/delete_test.go b/internal/cmd/server/backup/delete/delete_test.go index d1f455b3d..1b90f5c00 100644 --- a/internal/cmd/server/backup/delete/delete_test.go +++ b/internal/cmd/server/backup/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -129,54 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/backup/describe/describe.go b/internal/cmd/server/backup/describe/describe.go index b1fe36663..b303028c3 100644 --- a/internal/cmd/server/backup/describe/describe.go +++ b/internal/cmd/server/backup/describe/describe.go @@ -2,11 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" "strconv" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +32,7 @@ type inputModel struct { BackupId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", backupIdArg), Short: "Shows details of a Server Backup", @@ -48,12 +48,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,7 +65,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server backup: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -93,15 +93,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu BackupId: backupId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -111,24 +103,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbacku } func outputResult(p *print.Printer, outputFormat string, backup serverbackup.Backup) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(backup, "", " ") - if err != nil { - return fmt.Errorf("marshal server backup: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(backup, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server backup: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, backup, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(backup.Id)) table.AddSeparator() @@ -159,5 +134,5 @@ func outputResult(p *print.Printer, outputFormat string, backup serverbackup.Bac } return nil - } + }) } diff --git a/internal/cmd/server/backup/describe/describe_test.go b/internal/cmd/server/backup/describe/describe_test.go index 2760e7e7c..1550a9c4e 100644 --- a/internal/cmd/server/backup/describe/describe_test.go +++ b/internal/cmd/server/backup/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -129,54 +132,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -236,7 +192,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.backup); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/backup/disable/disable.go b/internal/cmd/server/backup/disable/disable.go index 0a76151a5..4aecf6553 100644 --- a/internal/cmd/server/backup/disable/disable.go +++ b/internal/cmd/server/backup/disable/disable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "disable", Short: "Disables Server Backup service", @@ -39,25 +41,25 @@ func NewCmd(p *print.Printer) *cobra.Command { `Disable Server Backup functionality for your server.`, "$ stackit server backup disable --server-id=zzz"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } @@ -68,16 +70,14 @@ func NewCmd(p *print.Printer) *cobra.Command { return err } if !canDisable { - p.Info("Cannot disable backup service for server %s - existing backups or existing backup schedules found\n", serverLabel) + params.Printer.Info("Cannot disable backup service for server %s - existing backups or existing backup schedules found\n", serverLabel) return nil } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to disable the backup service for server %s?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to disable the backup service for server %s?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -87,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("disable server backup service: %w", err) } - p.Info("Disabled Server Backup service for server %s\n", serverLabel) + params.Printer.Info("Disabled Server Backup service for server %s\n", serverLabel) return nil }, } @@ -102,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -113,15 +113,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/backup/disable/disable_test.go b/internal/cmd/server/backup/disable/disable_test.go index 7b13233e1..9e74ae6ee 100644 --- a/internal/cmd/server/backup/disable/disable_test.go +++ b/internal/cmd/server/backup/disable/disable_test.go @@ -5,12 +5,11 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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/serverbackup" ) @@ -26,6 +25,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st flagValues := map[string]string{ globalflags.ProjectIdFlag: testProjectId, globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -59,17 +59,23 @@ func fixtureRequest(mods ...func(request *serverbackup.ApiDisableServiceResource func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel }{ { - description: "base", - flagValues: fixtureFlagValues(), - isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.ServerId = "" + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "server id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" }), + isValid: false, }, { description: "no values", @@ -101,46 +107,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/backup/enable/enable.go b/internal/cmd/server/backup/enable/enable.go index 9f0f48e1b..7f6854be3 100644 --- a/internal/cmd/server/backup/enable/enable.go +++ b/internal/cmd/server/backup/enable/enable.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "enable", Short: "Enables Server Backup service", @@ -39,36 +41,34 @@ func NewCmd(p *print.Printer) *cobra.Command { `Enable Server Backup functionality for your server`, "$ stackit server backup enable --server-id=zzz"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to enable the Server Backup service for server %s?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to enable the Server Backup service for server %s?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -80,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } } - p.Info("Enabled backup service for server %s\n", serverLabel) + params.Printer.Info("Enabled backup service for server %s\n", serverLabel) return nil }, } @@ -95,7 +95,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -106,15 +106,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/backup/enable/enable_test.go b/internal/cmd/server/backup/enable/enable_test.go index b4cc1cb77..b7035a762 100644 --- a/internal/cmd/server/backup/enable/enable_test.go +++ b/internal/cmd/server/backup/enable/enable_test.go @@ -5,12 +5,11 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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/serverbackup" ) @@ -26,6 +25,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st flagValues := map[string]string{ globalflags.ProjectIdFlag: testProjectId, globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -59,17 +59,23 @@ func fixtureRequest(mods ...func(request *serverbackup.ApiEnableServiceResourceR func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel }{ { - description: "base", - flagValues: fixtureFlagValues(), - isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.ServerId = "" + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "server id is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" }), + isValid: false, }, { description: "no values", @@ -101,46 +107,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/backup/list/list.go b/internal/cmd/server/backup/list/list.go index d7cbfeb6f..86e2ac155 100644 --- a/internal/cmd/server/backup/list/list.go +++ b/internal/cmd/server/backup/list/list.go @@ -2,23 +2,25 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serverbackup" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverbackup/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serverbackup" ) const ( @@ -32,7 +34,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all server backups", @@ -46,15 +48,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all backups for a server with ID "xxx" in JSON format`, "$ stackit server backup list --server-id xxx --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -69,15 +71,15 @@ func NewCmd(p *print.Printer) *cobra.Command { if len(backups) == 0 { serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - p.Info("No backups found for server %s\n", serverLabel) + params.Printer.Info("No backups found for server %s\n", serverLabel) return nil } @@ -85,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(backups) > int(*model.Limit) { backups = backups[:*model.Limit] } - return outputResult(p, model.OutputFormat, backups) + return outputResult(params.Printer, model.OutputFormat, backups) }, } configureFlags(cmd) @@ -100,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -120,15 +122,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -138,24 +132,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbacku } func outputResult(p *print.Printer, outputFormat string, backups []serverbackup.Backup) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(backups, "", " ") - if err != nil { - return fmt.Errorf("marshal Server Backup list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(backups, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Server Backup list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, backups, func() error { table := tables.NewTable() table.SetHeader("ID", "NAME", "SIZE (GB)", "STATUS", "CREATED AT", "EXPIRES AT", "LAST RESTORED AT", "VOLUME BACKUPS") for i := range backups { @@ -182,5 +159,5 @@ func outputResult(p *print.Printer, outputFormat string, backups []serverbackup. return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/backup/list/list_test.go b/internal/cmd/server/backup/list/list_test.go index c9f5b9c2e..a316ee0c4 100644 --- a/internal/cmd/server/backup/list/list_test.go +++ b/internal/cmd/server/backup/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -62,6 +65,7 @@ func fixtureRequest(mods ...func(request *serverbackup.ApiListBackupsRequest)) s func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -116,46 +120,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -214,7 +179,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.backups); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/backup/restore/restore.go b/internal/cmd/server/backup/restore/restore.go index 67bbff395..b55b2b465 100644 --- a/internal/cmd/server/backup/restore/restore.go +++ b/internal/cmd/server/backup/restore/restore.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -34,7 +36,7 @@ type inputModel struct { BackupVolumeIds []string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("restore %s", backupIdArg), Short: "Restores a Server Backup.", @@ -50,23 +52,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to restore server backup %q? (This cannot be undone)", model.BackupId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to restore server backup %q? (This cannot be undone)", model.BackupId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -76,7 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("restore Server Backup: %w", err) } - p.Info("Triggered restoring of server backup %q\n", model.BackupId) + params.Printer.Info("Triggered restoring of server backup %q\n", model.BackupId) return nil }, } @@ -109,15 +109,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu StartServerAfterRestore: flags.FlagToBoolValue(p, cmd, startServerAfterRestoreFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/backup/restore/restore_test.go b/internal/cmd/server/backup/restore/restore_test.go index c352d56cd..9f890f3c2 100644 --- a/internal/cmd/server/backup/restore/restore_test.go +++ b/internal/cmd/server/backup/restore/restore_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -131,54 +131,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/backup/schedule/create/create.go b/internal/cmd/server/backup/schedule/create/create.go index 716e0d5c0..6b722c0b0 100644 --- a/internal/cmd/server/backup/schedule/create/create.go +++ b/internal/cmd/server/backup/schedule/create/create.go @@ -2,17 +2,18 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverbackup/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -47,7 +48,7 @@ type inputModel struct { BackupVolumeIds []string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Server Backup Schedule", @@ -61,37 +62,35 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a Server Backup Schedule with name "myschedule", backup name "mybackup" and retention period of 5 days`, `$ stackit server backup schedule create --server-id xxx --backup-name=mybackup --backup-schedule-name=myschedule --backup-retention-period=5`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a Backup Schedule for server %s?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a Backup Schedule for server %s?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -104,7 +103,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Server Backup Schedule: %w", err) } - return outputResult(p, model.OutputFormat, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp) }, } configureFlags(cmd) @@ -124,7 +123,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -141,15 +140,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { BackupVolumeIds: flags.FlagToStringSliceValue(p, cmd, backupVolumeIdsFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -173,25 +164,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbacku } func outputResult(p *print.Printer, outputFormat, serverLabel string, resp serverbackup.BackupSchedule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal server backup schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server backup schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created server backup schedule for server %s. Backup Schedule ID: %s\n", serverLabel, utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/server/backup/schedule/create/create_test.go b/internal/cmd/server/backup/schedule/create/create_test.go index 00ec85560..548a93bd4 100644 --- a/internal/cmd/server/backup/schedule/create/create_test.go +++ b/internal/cmd/server/backup/schedule/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -92,6 +95,7 @@ func fixturePayload(mods ...func(payload *serverbackup.CreateBackupSchedulePaylo func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -141,46 +145,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -238,7 +203,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/backup/schedule/delete/delete.go b/internal/cmd/server/backup/schedule/delete/delete.go index e2dcaa7a0..42e4c07ef 100644 --- a/internal/cmd/server/backup/schedule/delete/delete.go +++ b/internal/cmd/server/backup/schedule/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +31,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", scheduleIdArg), Short: "Deletes a Server Backup Schedule", @@ -42,34 +44,32 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete server backup schedule %q? (This cannot be undone)", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete server backup schedule %q? (This cannot be undone)", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -79,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Server Backup Schedule: %w", err) } - p.Info("Deleted server backup schedule %q\n", model.ScheduleId) + params.Printer.Info("Deleted server backup schedule %q\n", model.ScheduleId) return nil }, } @@ -108,15 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/backup/schedule/delete/delete_test.go b/internal/cmd/server/backup/schedule/delete/delete_test.go index 7915f1c07..5e0f108ca 100644 --- a/internal/cmd/server/backup/schedule/delete/delete_test.go +++ b/internal/cmd/server/backup/schedule/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -129,54 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/backup/schedule/describe/describe.go b/internal/cmd/server/backup/schedule/describe/describe.go index 990032499..a4e3b1f21 100644 --- a/internal/cmd/server/backup/schedule/describe/describe.go +++ b/internal/cmd/server/backup/schedule/describe/describe.go @@ -2,10 +2,10 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -31,7 +31,7 @@ type inputModel struct { BackupScheduleId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", backupScheduleIdArg), Short: "Shows details of a Server Backup Schedule", @@ -47,12 +47,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -64,7 +64,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server backup schedule: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -92,15 +92,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu BackupScheduleId: backupScheduleId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -110,24 +102,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbacku } func outputResult(p *print.Printer, outputFormat string, schedule serverbackup.BackupSchedule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(schedule, "", " ") - if err != nil { - return fmt.Errorf("marshal server backup schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(schedule, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server backup schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, schedule, func() error { table := tables.NewTable() table.AddRow("SCHEDULE ID", utils.PtrString(schedule.Id)) table.AddSeparator() @@ -151,5 +126,5 @@ func outputResult(p *print.Printer, outputFormat string, schedule serverbackup.B } return nil - } + }) } diff --git a/internal/cmd/server/backup/schedule/describe/describe_test.go b/internal/cmd/server/backup/schedule/describe/describe_test.go index f94627dc3..7ce57f910 100644 --- a/internal/cmd/server/backup/schedule/describe/describe_test.go +++ b/internal/cmd/server/backup/schedule/describe/describe_test.go @@ -4,12 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serverbackup" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/serverbackup" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) type testCtxKey struct{} @@ -128,54 +132,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -263,7 +220,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.schedule); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/backup/schedule/list/list.go b/internal/cmd/server/backup/schedule/list/list.go index 2920e9853..7c573fcc9 100644 --- a/internal/cmd/server/backup/schedule/list/list.go +++ b/internal/cmd/server/backup/schedule/list/list.go @@ -2,23 +2,25 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serverbackup" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverbackup/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serverbackup" ) const ( @@ -32,7 +34,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all server backup schedules", @@ -46,25 +48,25 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all backup schedules for a server with ID "xxx" in JSON format`, "$ stackit server backup schedule list --server-id xxx --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } @@ -78,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } schedules := *resp.Items if len(schedules) == 0 { - p.Info("No backup schedules found for server %s\n", serverLabel) + params.Printer.Info("No backup schedules found for server %s\n", serverLabel) return nil } @@ -86,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(schedules) > int(*model.Limit) { schedules = schedules[:*model.Limit] } - return outputResult(p, model.OutputFormat, schedules) + return outputResult(params.Printer, model.OutputFormat, schedules) }, } configureFlags(cmd) @@ -101,7 +103,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -121,15 +123,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -139,24 +133,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbacku } func outputResult(p *print.Printer, outputFormat string, schedules []serverbackup.BackupSchedule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(schedules, "", " ") - if err != nil { - return fmt.Errorf("marshal Server Backup Schedules list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(schedules, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Server Backup Schedules list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, schedules, func() error { table := tables.NewTable() table.SetHeader("SCHEDULE ID", "SCHEDULE NAME", "ENABLED", "RRULE", "BACKUP NAME", "BACKUP RETENTION DAYS", "BACKUP VOLUME IDS") for i := range schedules { @@ -186,5 +163,5 @@ func outputResult(p *print.Printer, outputFormat string, schedules []serverbacku return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/backup/schedule/list/list_test.go b/internal/cmd/server/backup/schedule/list/list_test.go index 6c055bcef..9c08b0f5d 100644 --- a/internal/cmd/server/backup/schedule/list/list_test.go +++ b/internal/cmd/server/backup/schedule/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -62,6 +65,7 @@ func fixtureRequest(mods ...func(request *serverbackup.ApiListBackupSchedulesReq func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -116,46 +120,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -225,7 +190,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.schedules); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/backup/schedule/schedule.go b/internal/cmd/server/backup/schedule/schedule.go index 423486fde..fd93c4cde 100644 --- a/internal/cmd/server/backup/schedule/schedule.go +++ b/internal/cmd/server/backup/schedule/schedule.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/schedule/list" "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/schedule/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "schedule", Short: "Provides functionality for Server Backup Schedule", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(del.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(del.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/server/backup/schedule/update/update.go b/internal/cmd/server/backup/schedule/update/update.go index 9885cc2a1..d16db4d63 100644 --- a/internal/cmd/server/backup/schedule/update/update.go +++ b/internal/cmd/server/backup/schedule/update/update.go @@ -2,10 +2,10 @@ package update import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -48,7 +48,7 @@ type inputModel struct { BackupVolumeIds []string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", scheduleIdArg), Short: "Updates a Server Backup Schedule", @@ -65,29 +65,27 @@ func NewCmd(p *print.Printer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } currentBackupSchedule, err := apiClient.GetBackupScheduleExecute(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupScheduleId) if err != nil { - p.Debug(print.ErrorLevel, "get current server backup schedule: %v", err) + params.Printer.Debug(print.ErrorLevel, "get current server backup schedule: %v", err) return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update Server Backup Schedule %q?", model.BackupScheduleId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update Server Backup Schedule %q?", model.BackupScheduleId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -100,7 +98,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update Server Backup Schedule: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -141,15 +139,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu BackupVolumeIds: flags.FlagToStringSliceValue(p, cmd, backupVolumeIdsFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -185,25 +175,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbacku } func outputResult(p *print.Printer, outputFormat string, resp serverbackup.BackupSchedule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal update server backup schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal update server backup schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Info("Updated server backup schedule %s\n", utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/server/backup/schedule/update/update_test.go b/internal/cmd/server/backup/schedule/update/update_test.go index 8b127b235..fc42794a0 100644 --- a/internal/cmd/server/backup/schedule/update/update_test.go +++ b/internal/cmd/server/backup/schedule/update/update_test.go @@ -5,6 +5,8 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -183,7 +185,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -295,7 +297,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/backup/volume-backup/delete/delete.go b/internal/cmd/server/backup/volume-backup/delete/delete.go index 9e3de47a8..1a7241bba 100644 --- a/internal/cmd/server/backup/volume-backup/delete/delete.go +++ b/internal/cmd/server/backup/volume-backup/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", volumeBackupIdArg), Short: "Deletes a Server Volume Backup.", @@ -43,23 +45,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete server volume backup %q? (This cannot be undone)", model.VolumeId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete server volume backup %q? (This cannot be undone)", model.VolumeId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -69,7 +69,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Server Volume Backup: %w", err) } - p.Info("Triggered deletion of server volume backup %q\n", model.VolumeId) + params.Printer.Info("Triggered deletion of server volume backup %q\n", model.VolumeId) return nil }, } @@ -100,15 +100,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/backup/volume-backup/delete/delete_test.go b/internal/cmd/server/backup/volume-backup/delete/delete_test.go index 59af68770..0a1da5857 100644 --- a/internal/cmd/server/backup/volume-backup/delete/delete_test.go +++ b/internal/cmd/server/backup/volume-backup/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -132,54 +132,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/backup/volume-backup/restore/restore.go b/internal/cmd/server/backup/volume-backup/restore/restore.go index 8e0881d1f..343f4ade8 100644 --- a/internal/cmd/server/backup/volume-backup/restore/restore.go +++ b/internal/cmd/server/backup/volume-backup/restore/restore.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +34,7 @@ type inputModel struct { RestoreVolumeId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("restore %s", volumeBackupIdArg), Short: "Restore a Server Volume Backup to a volume.", @@ -45,23 +47,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to restore volume backup %q? (This cannot be undone)", model.VolumeBackupId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to restore volume backup %q? (This cannot be undone)", model.VolumeBackupId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -71,7 +71,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("restore Server Volume Backup: %w", err) } - p.Info("Triggered restoring of server volume backup %q\n", model.VolumeBackupId) + params.Printer.Info("Triggered restoring of server volume backup %q\n", model.VolumeBackupId) return nil }, } @@ -104,15 +104,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu RestoreVolumeId: flags.FlagToStringValue(p, cmd, restoreVolumeIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/backup/volume-backup/restore/restore_test.go b/internal/cmd/server/backup/volume-backup/restore/restore_test.go index cb3701baa..f329161db 100644 --- a/internal/cmd/server/backup/volume-backup/restore/restore_test.go +++ b/internal/cmd/server/backup/volume-backup/restore/restore_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -138,54 +138,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/backup/volume-backup/volumebackup.go b/internal/cmd/server/backup/volume-backup/volumebackup.go index 7a4024eb4..5bdf0f72d 100644 --- a/internal/cmd/server/backup/volume-backup/volumebackup.go +++ b/internal/cmd/server/backup/volume-backup/volumebackup.go @@ -4,13 +4,13 @@ import ( del "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/volume-backup/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/volume-backup/restore" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "volume-backup", Short: "Provides functionality for Server Backup Volume Backups", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(del.NewCmd(p)) - cmd.AddCommand(restore.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(del.NewCmd(params)) + cmd.AddCommand(restore.NewCmd(params)) } diff --git a/internal/cmd/server/command/command.go b/internal/cmd/server/command/command.go index 9347fb111..ccd8978fd 100644 --- a/internal/cmd/server/command/command.go +++ b/internal/cmd/server/command/command.go @@ -6,13 +6,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/command/list" "github.com/stackitcloud/stackit-cli/internal/cmd/server/command/template" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "command", Short: "Provides functionality for Server Command", @@ -20,13 +20,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(template.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(template.NewCmd(params)) } diff --git a/internal/cmd/server/command/create/create.go b/internal/cmd/server/command/create/create.go index ed65528ac..9259e3046 100644 --- a/internal/cmd/server/command/create/create.go +++ b/internal/cmd/server/command/create/create.go @@ -2,23 +2,25 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/services/runcommand/client" runcommandUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/runcommand/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/runcommand" ) const ( @@ -35,7 +37,7 @@ type inputModel struct { Params *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Server Command", @@ -49,37 +51,35 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a server command for server with ID "xxx", template name "RunShellScript" and a script provided on the command line`, `$ stackit server command create --server-id xxx --template-name=RunShellScript --params script='echo hello'`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a Command for server %s?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a Command for server %s?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -92,7 +92,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Server Command: %w", err) } - return outputResult(p, model.OutputFormat, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp) }, } configureFlags(cmd) @@ -108,7 +108,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -120,24 +120,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { CommandTemplateName: flags.FlagToStringValue(p, cmd, commandTemplateNameFlag), Params: flags.FlagToStringToStringPointer(p, cmd, paramsFlag), } - parsedParams, err := runcommandUtils.ParseScriptParams(*model.Params) + + var err error + model.Params, err = runcommandUtils.ParseScriptParams(model.Params) if err != nil { return nil, &cliErr.FlagValidationError{ Flag: paramsFlag, Details: err.Error(), } } - model.Params = &parsedParams - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } + p.DebugInputModel(model) return &model, nil } @@ -151,25 +144,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *runcommand. } func outputResult(p *print.Printer, outputFormat, serverLabel string, resp runcommand.NewCommandResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal server command: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server command: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created server command for server %s. Command ID: %s\n", serverLabel, utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/server/command/create/create_test.go b/internal/cmd/server/command/create/create_test.go index b7b3657ca..8029d65e5 100644 --- a/internal/cmd/server/command/create/create_test.go +++ b/internal/cmd/server/command/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -80,6 +83,7 @@ func fixturePayload(mods ...func(payload *runcommand.CreateCommandPayload)) runc func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -101,6 +105,16 @@ func TestParseInput(t *testing.T) { flagValues: map[string]string{}, isValid: false, }, + { + description: "params flag missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, paramsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Params = nil + }), + }, { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { @@ -126,46 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -223,7 +198,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/command/describe/describe.go b/internal/cmd/server/command/describe/describe.go index 6eb4359c7..520ead530 100644 --- a/internal/cmd/server/command/describe/describe.go +++ b/internal/cmd/server/command/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/runcommand/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/runcommand" ) const ( @@ -30,7 +31,7 @@ type inputModel struct { CommandId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", commandIdArg), Short: "Shows details of a Server Command", @@ -46,12 +47,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +64,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server command: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -91,15 +92,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CommandId: commandId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -109,24 +102,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *runcommand. } func outputResult(p *print.Printer, outputFormat string, command runcommand.CommandDetails) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(command, "", " ") - if err != nil { - return fmt.Errorf("marshal server command: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(command, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server command: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, command, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(command.Id)) table.AddSeparator() @@ -152,5 +128,5 @@ func outputResult(p *print.Printer, outputFormat string, command runcommand.Comm } return nil - } + }) } diff --git a/internal/cmd/server/command/describe/describe_test.go b/internal/cmd/server/command/describe/describe_test.go index c9d1f3d86..5ad83715a 100644 --- a/internal/cmd/server/command/describe/describe_test.go +++ b/internal/cmd/server/command/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -162,54 +165,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -261,7 +217,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.command); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/command/list/list.go b/internal/cmd/server/command/list/list.go index 4a00abf57..21a96bae1 100644 --- a/internal/cmd/server/command/list/list.go +++ b/internal/cmd/server/command/list/list.go @@ -2,23 +2,25 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/services/runcommand/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/runcommand" ) const ( @@ -32,7 +34,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all server commands", @@ -46,25 +48,25 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all commands for a server with ID "xxx" in JSON format`, "$ stackit server command list --server-id xxx --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } @@ -77,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("list server commands: %w", err) } if commands := resp.Items; commands == nil || len(*commands) == 0 { - p.Info("No commands found for server %s\n", serverLabel) + params.Printer.Info("No commands found for server %s\n", serverLabel) return nil } commands := *resp.Items @@ -85,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(commands) > int(*model.Limit) { commands = commands[:*model.Limit] } - return outputResult(p, model.OutputFormat, commands) + return outputResult(params.Printer, model.OutputFormat, commands) }, } configureFlags(cmd) @@ -100,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -120,15 +122,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -138,24 +132,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *runcommand. } func outputResult(p *print.Printer, outputFormat string, commands []runcommand.Commands) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(commands, "", " ") - if err != nil { - return fmt.Errorf("marshal server command list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(commands, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server command list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, commands, func() error { table := tables.NewTable() table.SetHeader("ID", "TEMPLATE NAME", "TEMPLATE TITLE", "STATUS", "STARTED_AT", "FINISHED_AT") for i := range commands { @@ -174,5 +151,5 @@ func outputResult(p *print.Printer, outputFormat string, commands []runcommand.C return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/command/list/list_test.go b/internal/cmd/server/command/list/list_test.go index 6740fac38..bb806598d 100644 --- a/internal/cmd/server/command/list/list_test.go +++ b/internal/cmd/server/command/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -65,6 +68,7 @@ func fixtureRequest(mods ...func(request *runcommand.ApiListCommandsRequest)) ru func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -119,46 +123,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -208,7 +173,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.commands); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/command/template/describe/describe.go b/internal/cmd/server/command/template/describe/describe.go index 816bfa8be..486e5c6ac 100644 --- a/internal/cmd/server/command/template/describe/describe.go +++ b/internal/cmd/server/command/template/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/runcommand/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/runcommand" ) const ( @@ -30,7 +31,7 @@ type inputModel struct { CommandTemplateName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", commandTemplateNameArg), Short: "Shows details of a Server Command Template", @@ -46,12 +47,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +64,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server command template: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -91,15 +92,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu CommandTemplateName: commandTemplateName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -109,24 +102,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *runcommand. } func outputResult(p *print.Printer, outputFormat string, commandTemplate runcommand.CommandTemplateSchema) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(commandTemplate, "", " ") - if err != nil { - return fmt.Errorf("marshal server command template: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(commandTemplate, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server command template: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, commandTemplate, func() error { table := tables.NewTable() table.AddRow("NAME", utils.PtrString(commandTemplate.Name)) table.AddSeparator() @@ -138,8 +114,8 @@ func outputResult(p *print.Printer, outputFormat string, commandTemplate runcomm table.AddRow("OS TYPE", utils.JoinStringPtr(commandTemplate.OsType, "\n")) table.AddSeparator() } - if commandTemplate.ParameterSchema != nil { - table.AddRow("PARAMS", *commandTemplate.ParameterSchema) + if commandTemplate.ParametersSchema != nil { + table.AddRow("PARAMS", *commandTemplate.ParametersSchema) } else { table.AddRow("PARAMS", "") } @@ -150,5 +126,5 @@ func outputResult(p *print.Printer, outputFormat string, commandTemplate runcomm } return nil - } + }) } diff --git a/internal/cmd/server/command/template/describe/describe_test.go b/internal/cmd/server/command/template/describe/describe_test.go index 85b97a8ea..aaa90096a 100644 --- a/internal/cmd/server/command/template/describe/describe_test.go +++ b/internal/cmd/server/command/template/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -162,54 +165,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -261,7 +217,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.commandTemplate); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/command/template/list/list.go b/internal/cmd/server/command/template/list/list.go index 079c4f5f7..90ce846ca 100644 --- a/internal/cmd/server/command/template/list/list.go +++ b/internal/cmd/server/command/template/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/runcommand" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/runcommand/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/runcommand" ) const ( @@ -28,7 +29,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all server command templates", @@ -42,15 +43,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all commands templates in JSON format`, "$ stackit server command template list --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -62,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("list server command templates: %w", err) } if templates := resp.Items; templates == nil || len(*templates) == 0 { - p.Info("No commands templates found\n") + params.Printer.Info("No commands templates found\n") return nil } templates := *resp.Items @@ -71,7 +72,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(templates) > int(*model.Limit) { templates = templates[:*model.Limit] } - return outputResult(p, model.OutputFormat, templates) + return outputResult(params.Printer, model.OutputFormat, templates) }, } configureFlags(cmd) @@ -82,7 +83,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -101,15 +102,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -119,24 +112,7 @@ func buildRequest(ctx context.Context, _ *inputModel, apiClient *runcommand.APIC } func outputResult(p *print.Printer, outputFormat string, templates []runcommand.CommandTemplate) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(templates, "", " ") - if err != nil { - return fmt.Errorf("marshal server command template list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(templates, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server command template list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, templates, func() error { table := tables.NewTable() table.SetHeader("NAME", "OS TYPE", "TITLE") for i := range templates { @@ -158,5 +134,5 @@ func outputResult(p *print.Printer, outputFormat string, templates []runcommand. return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/command/template/list/list_test.go b/internal/cmd/server/command/template/list/list_test.go index 309716c16..df57cce22 100644 --- a/internal/cmd/server/command/template/list/list_test.go +++ b/internal/cmd/server/command/template/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -56,6 +59,7 @@ func fixtureRequest(mods ...func(request *runcommand.ApiListCommandTemplatesRequ func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -110,46 +114,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -207,7 +172,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.templates); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/command/template/template.go b/internal/cmd/server/command/template/template.go index 03f9b13da..5607fa5c4 100644 --- a/internal/cmd/server/command/template/template.go +++ b/internal/cmd/server/command/template/template.go @@ -4,13 +4,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/command/template/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/server/command/template/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "template", Short: "Provides functionality for Server Command Template", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/server/console/console.go b/internal/cmd/server/console/console.go index a9964619c..48e061615 100644 --- a/internal/cmd/server/console/console.go +++ b/internal/cmd/server/console/console.go @@ -2,11 +2,13 @@ package console import ( "context" - "encoding/json" "fmt" "net/url" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -29,7 +30,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("console %s", serverIdArg), Short: "Gets a URL for server remote console", @@ -47,20 +48,20 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId @@ -73,7 +74,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("server console: %w", err) } - return outputResult(p, model.OutputFormat, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp) }, } return cmd @@ -92,41 +93,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: serverId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetServerConsoleRequest { - return apiClient.GetServerConsole(ctx, model.ProjectId, model.ServerId) + return apiClient.GetServerConsole(ctx, model.ProjectId, model.Region, model.ServerId) } func outputResult(p *print.Printer, outputFormat, serverLabel string, serverUrl iaas.ServerConsoleUrl) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(serverUrl, "", " ") - if err != nil { - return fmt.Errorf("marshal url: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(serverUrl, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal url: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, serverUrl, func() error { if _, ok := serverUrl.GetUrlOk(); !ok { return fmt.Errorf("server url is nil") } @@ -139,5 +115,5 @@ func outputResult(p *print.Printer, outputFormat, serverLabel string, serverUrl p.Outputf("Remote console URL %q for server %q\n", unescapedURL, serverLabel) return nil - } + }) } diff --git a/internal/cmd/server/console/console_test.go b/internal/cmd/server/console/console_test.go index 4d9b9e6c1..6fa46ba1b 100644 --- a/internal/cmd/server/console/console_test.go +++ b/internal/cmd/server/console/console_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -35,7 +40,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -48,6 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, } @@ -58,7 +65,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetServerConsoleRequest)) iaas.ApiGetServerConsoleRequest { - request := testClient.GetServerConsole(testCtx, testProjectId, testServerId) + request := testClient.GetServerConsole(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -96,7 +103,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -104,7 +111,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -128,54 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -234,7 +194,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.serverUrl); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/create/create.go b/internal/cmd/server/create/create.go index 69cfc627b..1efaac4f4 100644 --- a/internal/cmd/server/create/create.go +++ b/internal/cmd/server/create/create.go @@ -2,10 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,8 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -65,7 +66,7 @@ type inputModel struct { Volumes *[]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a server", @@ -106,34 +107,32 @@ func NewCmd(p *print.Printer) *cobra.Command { ), examples.NewExample( `Create a server with user data (cloud-init)`, - `$ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data @path/to/file.yaml")`, + `$ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --user-data @path/to/file.yaml`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a server for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a server for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -146,16 +145,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating server") - _, err = wait.CreateServerWaitHandler(ctx, apiClient, model.ProjectId, serverId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating server", func() error { + _, err = wait.CreateServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, serverId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for server creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) }, } configureFlags(cmd) @@ -164,7 +163,7 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(nameFlag, "n", "", "Server name") - cmd.Flags().String(machineTypeFlag, "", "Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html") + cmd.Flags().String(machineTypeFlag, "", "Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/products/compute-engine/server/basics/machine-types/") cmd.Flags().String(affinityGroupFlag, "", "The affinity group the server is assigned to") cmd.Flags().String(availabilityZoneFlag, "", "The availability zone of the server") cmd.Flags().String(bootVolumeSourceIdFlag, "", "ID of the source object of boot volume. It can be either an image or volume ID") @@ -186,10 +185,11 @@ func configureFlags(cmd *cobra.Command) { cmd.MarkFlagsMutuallyExclusive(imageIdFlag, bootVolumeSourceIdFlag) cmd.MarkFlagsMutuallyExclusive(imageIdFlag, bootVolumeSourceTypeFlag) cmd.MarkFlagsMutuallyExclusive(networkIdFlag, networkInterfaceIdsFlag) + cmd.MarkFlagsOneRequired(networkIdFlag, networkInterfaceIdsFlag) cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -266,28 +266,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Volumes: flags.FlagToStringSlicePointer(p, cmd, volumesFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateServerRequest { - req := apiClient.CreateServer(ctx, model.ProjectId) - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } + req := apiClient.CreateServer(ctx, model.ProjectId, model.Region) var userData *[]byte if model.UserData != nil { @@ -306,11 +290,11 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli ServiceAccountMails: model.ServiceAccountMails, UserData: userData, Volumes: model.Volumes, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } if model.BootVolumePerformanceClass != nil || model.BootVolumeSize != nil || model.BootVolumeDeleteOnTermination != nil || model.BootVolumeSourceId != nil || model.BootVolumeSourceType != nil { - payload.BootVolume = &iaas.CreateServerPayloadBootVolume{ + payload.BootVolume = &iaas.ServerBootVolume{ PerformanceClass: model.BootVolumePerformanceClass, Size: model.BootVolumeSize, DeleteOnTermination: model.BootVolumeDeleteOnTermination, @@ -322,7 +306,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } if model.NetworkInterfaceIds != nil || model.NetworkId != nil { - payload.Networking = &iaas.CreateServerPayloadNetworking{} + payload.Networking = &iaas.CreateServerPayloadAllOfNetworking{} if model.NetworkInterfaceIds != nil { payload.Networking.CreateServerNetworkingWithNics = &iaas.CreateServerNetworkingWithNics{ @@ -339,29 +323,16 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli return req.CreateServerPayload(payload) } -func outputResult(p *print.Printer, outputFormat, projectLabel string, server *iaas.Server) error { +func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, server *iaas.Server) error { if server == nil { return fmt.Errorf("server response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(server, "", " ") - if err != nil { - return fmt.Errorf("marshal server: %w", err) + return p.OutputResult(outputFormat, server, func() error { + operationState := "Created" + if async { + operationState = "Triggered creation of" } - p.Outputln(string(details)) - + p.Outputf("%s server for project %q.\nServer ID: %s\n", operationState, projectLabel, utils.PtrString(server.Id)) return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(server, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - p.Outputf("Created server for project %q.\nServer ID: %s\n", projectLabel, utils.PtrString(server.Id)) - return nil - } + }) } diff --git a/internal/cmd/server/create/create_test.go b/internal/cmd/server/create/create_test.go index bf8f815d5..7ab029013 100644 --- a/internal/cmd/server/create/create_test.go +++ b/internal/cmd/server/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -29,7 +34,9 @@ var testVolumeId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + availabilityZoneFlag: "eu01-1", nameFlag: "test-server-name", machineTypeFlag: "t1.1", @@ -40,7 +47,6 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st bootVolumeSourceIdFlag: testSourceId, bootVolumeSourceTypeFlag: "test-source-type", bootVolumeDeleteOnTerminationFlag: "false", - imageIdFlag: testImageId, keypairNameFlag: "test-keypair-name", networkIdFlag: testNetworkId, securityGroupsFlag: "test-security-groups", @@ -58,6 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, AvailabilityZone: utils.Ptr("eu01-1"), @@ -69,7 +76,6 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { BootVolumeSourceId: utils.Ptr(testSourceId), BootVolumeSourceType: utils.Ptr("test-source-type"), BootVolumeDeleteOnTermination: utils.Ptr(false), - ImageId: utils.Ptr(testImageId), KeypairName: utils.Ptr("test-keypair-name"), NetworkId: utils.Ptr(testNetworkId), SecurityGroups: utils.Ptr([]string{"test-security-groups"}), @@ -87,7 +93,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiCreateServerRequest)) iaas.ApiCreateServerRequest { - request := testClient.CreateServer(testCtx, testProjectId) + request := testClient.CreateServer(testCtx, testProjectId, testRegion) request = request.CreateServerPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -96,7 +102,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateServerRequest)) iaas.Api } func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateServerRequest)) iaas.ApiCreateServerRequest { - request := testClient.CreateServer(testCtx, testProjectId) + request := testClient.CreateServer(testCtx, testProjectId, testRegion) request = request.CreateServerPayload(iaas.CreateServerPayload{ MachineType: utils.Ptr("t1.1"), Name: utils.Ptr("test-server-name"), @@ -116,13 +122,12 @@ func fixturePayload(mods ...func(payload *iaas.CreateServerPayload)) iaas.Create Name: utils.Ptr("test-server-name"), AvailabilityZone: utils.Ptr("eu01-1"), AffinityGroup: utils.Ptr("test-affinity-group"), - ImageId: utils.Ptr(testImageId), KeypairName: utils.Ptr("test-keypair-name"), SecurityGroups: utils.Ptr([]string{"test-security-groups"}), ServiceAccountMails: utils.Ptr([]string{"test-service-account"}), UserData: utils.Ptr([]byte("test-user-data")), Volumes: utils.Ptr([]string{testVolumeId}), - BootVolume: &iaas.CreateServerPayloadBootVolume{ + BootVolume: &iaas.ServerBootVolume{ PerformanceClass: utils.Ptr("test-perf-class"), Size: utils.Ptr(int64(5)), DeleteOnTermination: utils.Ptr(false), @@ -131,7 +136,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateServerPayload)) iaas.Create Type: utils.Ptr("test-source-type"), }, }, - Networking: &iaas.CreateServerPayloadNetworking{ + Networking: &iaas.CreateServerPayloadAllOfNetworking{ CreateServerNetworking: &iaas.CreateServerNetworking{ NetworkId: utils.Ptr(testNetworkId), }, @@ -146,6 +151,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateServerPayload)) iaas.Create func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -168,12 +174,12 @@ func TestParseInput(t *testing.T) { delete(flagValues, bootVolumePerformanceClassFlag) delete(flagValues, bootVolumeDeleteOnTerminationFlag) delete(flagValues, keypairNameFlag) - delete(flagValues, networkIdFlag) delete(flagValues, networkInterfaceIdsFlag) delete(flagValues, securityGroupsFlag) delete(flagValues, serviceAccountEmailsFlag) delete(flagValues, userDataFlag) delete(flagValues, volumesFlag) + flagValues[imageIdFlag] = testImageId }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { @@ -186,12 +192,12 @@ func TestParseInput(t *testing.T) { model.BootVolumePerformanceClass = nil model.BootVolumeDeleteOnTermination = nil model.KeypairName = nil - model.NetworkId = nil model.NetworkInterfaceIds = nil model.SecurityGroups = nil model.ServiceAccountMails = nil model.UserData = nil model.Volumes = nil + model.ImageId = utils.Ptr(testImageId) }), }, { @@ -216,21 +222,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -291,12 +297,14 @@ func TestParseInput(t *testing.T) { delete(flagValues, bootVolumeSourceIdFlag) delete(flagValues, bootVolumeSourceTypeFlag) delete(flagValues, bootVolumeSizeFlag) + flagValues[imageIdFlag] = testImageId }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { model.BootVolumeSourceId = nil model.BootVolumeSourceType = nil model.BootVolumeSize = nil + model.ImageId = utils.Ptr(testImageId) }), }, { @@ -325,46 +333,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -385,6 +354,7 @@ func TestBuildRequest(t *testing.T) { model: &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, MachineType: utils.Ptr("t1.1"), @@ -412,6 +382,7 @@ func TestBuildRequest(t *testing.T) { func TestOutputResult(t *testing.T) { type args struct { outputFormat string + async bool projectLabel string server *iaas.Server } @@ -434,10 +405,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.server); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.server); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/server/deallocate/deallocate.go b/internal/cmd/server/deallocate/deallocate.go index 53a01de69..41886042d 100644 --- a/internal/cmd/server/deallocate/deallocate.go +++ b/internal/cmd/server/deallocate/deallocate.go @@ -4,6 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +18,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -28,7 +31,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("deallocate %s", serverIdArg), Short: "Deallocates an existing server", @@ -42,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to deallocate server %q?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to deallocate server %q?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,20 +79,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deallocating server") - _, err = wait.DeallocateServerWaitHandler(ctx, apiClient, model.ProjectId, model.ServerId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deallocating server", func() error { + _, err = wait.DeallocateServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for server deallocating: %w", err) } - s.Stop() } operationState := "Deallocated" if model.Async { operationState = "Triggered deallocation of" } - p.Info("%s server %q\n", operationState, serverLabel) + params.Printer.Info("%s server %q\n", operationState, serverLabel) return nil }, @@ -112,18 +113,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: serverId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeallocateServerRequest { - return apiClient.DeallocateServer(ctx, model.ProjectId, model.ServerId) + return apiClient.DeallocateServer(ctx, model.ProjectId, model.Region, model.ServerId) } diff --git a/internal/cmd/server/deallocate/deallocate_test.go b/internal/cmd/server/deallocate/deallocate_test.go index 6ededf983..efb00e27f 100644 --- a/internal/cmd/server/deallocate/deallocate_test.go +++ b/internal/cmd/server/deallocate/deallocate_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, + Region: testRegion, ProjectId: testProjectId, }, ServerId: testServerId, @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeallocateServerRequest)) iaas.ApiDeallocateServerRequest { - request := testClient.DeallocateServer(testCtx, testProjectId, testServerId) + request := testClient.DeallocateServer(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -95,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -103,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -127,54 +131,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/delete/delete.go b/internal/cmd/server/delete/delete.go index 77ad35479..cbedd5a29 100644 --- a/internal/cmd/server/delete/delete.go +++ b/internal/cmd/server/delete/delete.go @@ -4,6 +4,13 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,10 +20,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" - - "github.com/spf13/cobra" ) const ( @@ -28,7 +31,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", serverIdArg), Short: "Deletes a server", @@ -45,31 +48,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete server %q?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete server %q?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,20 +82,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting server") - _, err = wait.DeleteServerWaitHandler(ctx, apiClient, model.ProjectId, model.ServerId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting server", func() error { + _, err = wait.DeleteServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for server deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s server %q\n", operationState, serverLabel) + params.Printer.Info("%s server %q\n", operationState, serverLabel) return nil }, } @@ -114,18 +115,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: serverId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteServerRequest { - return apiClient.DeleteServer(ctx, model.ProjectId, model.ServerId) + return apiClient.DeleteServer(ctx, model.ProjectId, model.Region, model.ServerId) } diff --git a/internal/cmd/server/delete/delete_test.go b/internal/cmd/server/delete/delete_test.go index 3b7c0ba31..9534c8b22 100644 --- a/internal/cmd/server/delete/delete_test.go +++ b/internal/cmd/server/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, } @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteServerRequest)) iaas.ApiDeleteServerRequest { - request := testClient.DeleteServer(testCtx, testProjectId, testServerId) + request := testClient.DeleteServer(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -101,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/describe/describe.go b/internal/cmd/server/describe/describe.go index d45086fb2..f13e6f5c8 100644 --- a/internal/cmd/server/describe/describe.go +++ b/internal/cmd/server/describe/describe.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +31,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", serverIdArg), Short: "Shows details of a server", @@ -47,13 +49,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,7 +67,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -84,20 +86,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: serverId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetServerRequest { - req := apiClient.GetServer(ctx, model.ProjectId, model.ServerId) + req := apiClient.GetServer(ctx, model.ProjectId, model.Region, model.ServerId) req = req.Details(true) return req @@ -117,7 +111,12 @@ func outputResult(p *print.Printer, outputFormat string, server *iaas.Server) er return nil case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(server, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + // This is a temporary workaround to get the desired base64 encoded yaml output for userdata + // and will be replaced by a fix in the Go-SDK + // ref: https://jira.schwarz/browse/STACKITSDK-246 + patchedServer := utils.ConvertToBase64PatchedServer(server) + + details, err := yaml.MarshalWithOptions(patchedServer, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) if err != nil { return fmt.Errorf("marshal server: %w", err) } diff --git a/internal/cmd/server/describe/describe_test.go b/internal/cmd/server/describe/describe_test.go index d16a3ec04..0b835a8da 100644 --- a/internal/cmd/server/describe/describe_test.go +++ b/internal/cmd/server/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +39,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ServerId: testServerId, @@ -57,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetServerRequest)) iaas.ApiGetServerRequest { - request := testClient.GetServer(testCtx, testProjectId, testServerId) + request := testClient.GetServer(testCtx, testProjectId, testRegion, testServerId) request = request.Details(true) for _, mod := range mods { mod(&request) @@ -102,7 +109,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -110,7 +117,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -118,7 +125,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -138,54 +145,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -243,7 +203,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.server); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/list/list.go b/internal/cmd/server/list/list.go index db72cdb26..54c058dc0 100644 --- a/internal/cmd/server/list/list.go +++ b/internal/cmd/server/list/list.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +34,7 @@ type inputModel struct { LabelSelector *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all servers of a project", @@ -56,15 +58,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit server list --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -77,12 +79,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No servers found for project %q\n", projectLabel) + params.Printer.Info("No servers found for project %q\n", projectLabel) return nil } @@ -92,7 +94,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -104,7 +106,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(labelSelectorFlag, "", "Filter by label") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -124,20 +126,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListServersRequest { - req := apiClient.ListServers(ctx, model.ProjectId) + req := apiClient.ListServers(ctx, model.ProjectId, model.Region) if model.LabelSelector != nil { req = req.LabelSelector(*model.LabelSelector) } @@ -157,7 +151,12 @@ func outputResult(p *print.Printer, outputFormat string, servers []iaas.Server) return nil case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(servers, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + // This is a temporary workaround to get the desired base64 encoded yaml output for userdata + // and will be replaced by a fix in the Go-SDK + // ref: https://jira.schwarz/browse/STACKITSDK-246 + patchedServers := utils.ConvertToBase64PatchedServers(servers) + + details, err := yaml.MarshalWithOptions(patchedServers, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) if err != nil { return fmt.Errorf("marshal server: %w", err) } diff --git a/internal/cmd/server/list/list_test.go b/internal/cmd/server/list/list_test.go index a8dfe0e1e..4eb3a78cf 100644 --- a/internal/cmd/server/list/list_test.go +++ b/internal/cmd/server/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -25,7 +30,9 @@ var testLabelSelector = "label" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", labelSelectorFlag: testLabelSelector, } @@ -40,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), LabelSelector: utils.Ptr(testLabelSelector), @@ -51,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListServersRequest)) iaas.ApiListServersRequest { - request := testClient.ListServers(testCtx, testProjectId) + request := testClient.ListServers(testCtx, testProjectId, testRegion) request = request.LabelSelector(testLabelSelector) request = request.Details(true) for _, mod := range mods { @@ -63,6 +71,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListServersRequest)) iaas.ApiL func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -86,21 +95,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -132,46 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -221,7 +191,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.servers); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/log/log.go b/internal/cmd/server/log/log.go index 646417b42..086e228b4 100644 --- a/internal/cmd/server/log/log.go +++ b/internal/cmd/server/log/log.go @@ -2,11 +2,13 @@ package log import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -34,7 +35,7 @@ type inputModel struct { Length *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("log %s", serverIdArg), Short: "Gets server console log", @@ -56,20 +57,20 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId @@ -88,10 +89,10 @@ func NewCmd(p *print.Printer) *cobra.Command { if len(lines) > int(*model.Length) { // Truncate output and show most recent logs start := len(lines) - int(*model.Length) - return outputResult(p, model.OutputFormat, serverLabel, strings.Join(lines[start:], "\n")) + return outputResult(params.Printer, model.OutputFormat, serverLabel, strings.Join(lines[start:], "\n")) } - return outputResult(p, model.OutputFormat, serverLabel, log) + return outputResult(params.Printer, model.OutputFormat, serverLabel, log) }, } configureFlags(cmd) @@ -124,42 +125,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Length: utils.Ptr(length), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetServerLogRequest { - return apiClient.GetServerLog(ctx, model.ProjectId, model.ServerId) + return apiClient.GetServerLog(ctx, model.ProjectId, model.Region, model.ServerId) } func outputResult(p *print.Printer, outputFormat, serverLabel, log string) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(log, "", " ") - if err != nil { - return fmt.Errorf("marshal url: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(log, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal url: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, log, func() error { p.Outputf("Log for server %q\n%s", serverLabel, log) return nil - } + }) } diff --git a/internal/cmd/server/log/log_test.go b/internal/cmd/server/log/log_test.go index 50ce7c723..87375530f 100644 --- a/internal/cmd/server/log/log_test.go +++ b/internal/cmd/server/log/log_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -35,7 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + lengthLimitFlag: "3000", } for _, mod := range mods { @@ -49,6 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, Length: utils.Ptr(int64(3000)), @@ -60,7 +68,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetServerLogRequest)) iaas.ApiGetServerLogRequest { - request := testClient.GetServerLog(testCtx, testProjectId, testServerId) + request := testClient.GetServerLog(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -98,7 +106,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -106,7 +114,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -141,54 +149,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -239,7 +200,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.log); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/machine-type/describe/describe.go b/internal/cmd/server/machine-type/describe/describe.go index 8c5d99e38..4bd07771e 100644 --- a/internal/cmd/server/machine-type/describe/describe.go +++ b/internal/cmd/server/machine-type/describe/describe.go @@ -2,10 +2,11 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -15,7 +16,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -29,7 +29,7 @@ type inputModel struct { MachineType string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", machineTypeArg), Short: "Shows details of a server machine type", @@ -47,13 +47,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -65,7 +65,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server machine type: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -84,44 +84,19 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu MachineType: machineType, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetMachineTypeRequest { - return apiClient.GetMachineType(ctx, model.ProjectId, model.MachineType) + return apiClient.GetMachineType(ctx, model.ProjectId, model.Region, model.MachineType) } func outputResult(p *print.Printer, outputFormat string, machineType *iaas.MachineType) error { if machineType == nil { return fmt.Errorf("api response for machine type is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(machineType, "", " ") - if err != nil { - return fmt.Errorf("marshal server machine type: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(machineType, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server machine type: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, machineType, func() error { table := tables.NewTable() table.AddRow("NAME", utils.PtrString(machineType.Name)) table.AddSeparator() @@ -139,5 +114,5 @@ func outputResult(p *print.Printer, outputFormat string, machineType *iaas.Machi return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/machine-type/describe/describe_test.go b/internal/cmd/server/machine-type/describe/describe_test.go index 32f6d9a0b..4343f6fb6 100644 --- a/internal/cmd/server/machine-type/describe/describe_test.go +++ b/internal/cmd/server/machine-type/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +39,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, MachineType: testMachineType, @@ -57,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetMachineTypeRequest)) iaas.ApiGetMachineTypeRequest { - request := testClient.GetMachineType(testCtx, testProjectId, testMachineType) + request := testClient.GetMachineType(testCtx, testProjectId, testRegion, testMachineType) for _, mod := range mods { mod(&request) } @@ -101,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -125,54 +132,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -222,7 +182,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.machineType); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/machine-type/list/list.go b/internal/cmd/server/machine-type/list/list.go index 19be072d3..c134edd55 100644 --- a/internal/cmd/server/machine-type/list/list.go +++ b/internal/cmd/server/machine-type/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -23,14 +23,16 @@ import ( type inputModel struct { *globalflags.GlobalFlagModel - Limit *int64 + Limit *int64 + Filter *string } const ( - limitFlag = "limit" + limitFlag = "limit" + filterFlag = "filter" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Get list of all machine types available in a project", @@ -49,16 +51,24 @@ func NewCmd(p *print.Printer) *cobra.Command { `List the first 10 machine types`, `$ stackit server machine-type list --limit=10`, ), + examples.NewExample( + `List machine types with exactly 2 vCPUs`, + `$ stackit server machine-type list --filter="vcpus==2"`, + ), + examples.NewExample( + `List machine types with at least 2 vCPUs and 2048 MB RAM`, + `$ stackit server machine-type list --filter="vcpus >= 2 && ram >= 2048"`, + ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -71,12 +81,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No machine-types found for project %q\n", projectLabel) + params.Printer.Info("No machine-types found for project %q\n", projectLabel) return nil } @@ -85,7 +95,7 @@ func NewCmd(p *print.Printer) *cobra.Command { *resp.Items = (*resp.Items)[:*model.Limit] } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } @@ -95,9 +105,10 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements") + cmd.Flags().String(filterFlag, "", "Filter resources by fields. A subset of expr-lang is supported. See https://expr-lang.org/docs/language-definition for usage details") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -113,51 +124,48 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { model := inputModel{ GlobalFlagModel: globalFlags, - Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), - } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + Limit: limit, + Filter: flags.FlagToStringPointer(p, cmd, filterFlag), } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListMachineTypesRequest { - return apiClient.ListMachineTypes(ctx, model.ProjectId) + req := apiClient.ListMachineTypes(ctx, model.ProjectId, model.Region) + if model.Filter != nil { + req = req.Filter(*model.Filter) + } + return req } func outputResult(p *print.Printer, outputFormat string, machineTypes iaas.MachineTypeListResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(machineTypes, "", " ") - if err != nil { - return fmt.Errorf("marshal machineTypes: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(machineTypes, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal machineTypes: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, machineTypes, func() error { table := tables.NewTable() table.SetTitle("Machine-Types") - - table.SetHeader("NAME", "DESCRIPTION") + table.SetHeader("NAME", "VCPUS", "RAM (GB)", "DESCRIPTION", "EXTRA SPECS") if items := machineTypes.GetItems(); len(items) > 0 { for _, machineType := range items { - table.AddRow(*machineType.Name, utils.PtrString(machineType.Description)) + extraSpecMap := make(map[string]string) + if machineType.ExtraSpecs != nil && len(*machineType.ExtraSpecs) > 0 { + for key, value := range *machineType.ExtraSpecs { + extraSpecMap[key] = fmt.Sprintf("%v", value) + } + } + ramGB := int64(0) + if machineType.Ram != nil { + ramGB = *machineType.Ram / 1024 + } + + table.AddRow( + utils.PtrString(machineType.Name), + utils.PtrValue(machineType.Vcpus), + ramGB, + utils.PtrString(machineType.Description), + utils.JoinStringMap(extraSpecMap, ": ", "\n"), + ) + table.AddSeparator() } } @@ -167,5 +175,5 @@ func outputResult(p *print.Printer, outputFormat string, machineTypes iaas.Machi } return nil - } + }) } diff --git a/internal/cmd/server/machine-type/list/list_test.go b/internal/cmd/server/machine-type/list/list_test.go index a38d4f386..19afe28b8 100644 --- a/internal/cmd/server/machine-type/list/list_test.go +++ b/internal/cmd/server/machine-type/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -24,8 +29,10 @@ var testProjectId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -38,6 +45,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), } @@ -48,7 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListMachineTypesRequest)) iaas.ApiListMachineTypesRequest { - request := testClient.ListMachineTypes(testCtx, testProjectId) + request := testClient.ListMachineTypes(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -58,6 +66,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListMachineTypesRequest)) iaas func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -68,6 +77,16 @@ func TestParseInput(t *testing.T) { isValid: true, expectedModel: fixtureInputModel(), }, + { + description: "with filter", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[filterFlag] = "vcpus >= 2 && ram >= 2048" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Filter = utils.Ptr("vcpus >= 2 && ram >= 2048") + }), + }, { description: "no values", flagValues: map[string]string{}, @@ -81,21 +100,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -117,46 +136,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -172,6 +152,15 @@ func TestBuildRequest(t *testing.T) { model: fixtureInputModel(), expectedRequest: fixtureRequest(), }, + { + description: "with filter", + model: fixtureInputModel(func(model *inputModel) { + model.Filter = utils.Ptr("vcpus==2") + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiListMachineTypesRequest) { + *request = (*request).Filter("vcpus==2") + }), + }, } for _, tt := range tests { @@ -204,9 +193,36 @@ func TestOutputResult(t *testing.T) { args: args{}, wantErr: false, }, + { + name: "with populated data", + args: args{ + outputFormat: "table", + machineTypes: iaas.MachineTypeListResponse{ + Items: &[]iaas.MachineType{ + { + Name: utils.Ptr("c1.2"), + Vcpus: utils.Ptr(int64(2)), + Ram: utils.Ptr(int64(2048)), // Should display as 2 GB + Description: utils.Ptr("Compute optimized 2 vCPU"), + ExtraSpecs: &map[string]interface{}{ + "cpu": "intel-icelake-generic", + }, + }, + { + Name: utils.Ptr("m1.2"), + Vcpus: utils.Ptr(int64(2)), + Ram: utils.Ptr(int64(8192)), // Should display as 8 GB + Description: utils.Ptr("Memory optimized 2 vCPU"), + // No ExtraSpecs provided to test nil safety + }, + }, + }, + }, + wantErr: false, + }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.machineTypes); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/machine-type/machine-type.go b/internal/cmd/server/machine-type/machine-type.go index 51c33d236..ee4e2ae54 100644 --- a/internal/cmd/server/machine-type/machine-type.go +++ b/internal/cmd/server/machine-type/machine-type.go @@ -4,13 +4,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/machine-type/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/server/machine-type/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "machine-type", Short: "Provides functionality for server machine types available inside a project", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/server/network-interface/attach/attach.go b/internal/cmd/server/network-interface/attach/attach.go index b2f987f27..9d1da9752 100644 --- a/internal/cmd/server/network-interface/attach/attach.go +++ b/internal/cmd/server/network-interface/attach/attach.go @@ -4,9 +4,12 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -15,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -35,7 +37,7 @@ type inputModel struct { Create *bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "attach", Short: "Attaches a network interface to a server", @@ -51,63 +53,61 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit server network-interface attach --network-id xxx --server-id yyy --create`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, *model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = *model.ServerId } // if the create flag is provided a network interface will be created and attached if model.Create != nil && *model.Create { - networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, *model.NetworkId) + networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, model.Region, *model.NetworkId) if err != nil { - p.Debug(print.ErrorLevel, "get network name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get network name: %v", err) networkLabel = *model.NetworkId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a network interface for network %q and attach it to server %q?", networkLabel, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a network interface for network %q and attach it to server %q?", networkLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } + // Call API req := buildRequestCreateAndAttach(ctx, model, apiClient) err = req.Execute() if err != nil { return fmt.Errorf("create and attach network interface: %w", err) } - p.Info("Created a network interface for network %q and attached it to server %q\n", networkLabel, serverLabel) + params.Printer.Info("Created a network interface for network %q and attached it to server %q\n", networkLabel, serverLabel) return nil } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to attach network interface %q to server %q?", *model.NicId, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to attach network interface %q to server %q?", *model.NicId, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } + // Call API req := buildRequestAttach(ctx, model, apiClient) err = req.Execute() if err != nil { return fmt.Errorf("attach network interface: %w", err) } - p.Info("Attached network interface %q to server %q\n", utils.PtrString(model.NicId), serverLabel) + params.Printer.Info("Attached network interface %q to server %q\n", utils.PtrString(model.NicId), serverLabel) return nil }, @@ -130,10 +130,10 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } // if create is not provided then network-interface-id is needed @@ -151,22 +151,14 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Create: create, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequestAttach(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddNicToServerRequest { - return apiClient.AddNicToServer(ctx, model.ProjectId, *model.ServerId, *model.NicId) + return apiClient.AddNicToServer(ctx, model.ProjectId, model.Region, *model.ServerId, *model.NicId) } func buildRequestCreateAndAttach(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddNetworkToServerRequest { - return apiClient.AddNetworkToServer(ctx, model.ProjectId, *model.ServerId, *model.NetworkId) + return apiClient.AddNetworkToServer(ctx, model.ProjectId, model.Region, *model.ServerId, *model.NetworkId) } diff --git a/internal/cmd/server/network-interface/attach/attach_test.go b/internal/cmd/server/network-interface/attach/attach_test.go index 2adeff447..6e2898b32 100644 --- a/internal/cmd/server/network-interface/attach/attach_test.go +++ b/internal/cmd/server/network-interface/attach/attach_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +14,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -28,7 +30,9 @@ var testNetworkId = uuid.NewString() // contains nic id func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, networkInterfaceIdFlag: testNicId, } @@ -43,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: utils.Ptr(testServerId), NicId: utils.Ptr(testNicId), @@ -54,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequestAttach(mods ...func(request *iaas.ApiAddNicToServerRequest)) iaas.ApiAddNicToServerRequest { - request := testClient.AddNicToServer(testCtx, testProjectId, testServerId, testNicId) + request := testClient.AddNicToServer(testCtx, testProjectId, testRegion, testServerId, testNicId) for _, mod := range mods { mod(&request) } @@ -62,7 +67,7 @@ func fixtureRequestAttach(mods ...func(request *iaas.ApiAddNicToServerRequest)) } func fixtureRequestCreateAndAttach(mods ...func(request *iaas.ApiAddNetworkToServerRequest)) iaas.ApiAddNetworkToServerRequest { - request := testClient.AddNetworkToServer(testCtx, testProjectId, testServerId, testNetworkId) + request := testClient.AddNetworkToServer(testCtx, testProjectId, testRegion, testServerId, testNetworkId) for _, mod := range mods { mod(&request) } @@ -72,6 +77,7 @@ func fixtureRequestCreateAndAttach(mods ...func(request *iaas.ApiAddNetworkToSer func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -90,21 +96,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -198,54 +204,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.ValidateFlagGroups() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flag groups: %v", err) - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/network-interface/detach/detach.go b/internal/cmd/server/network-interface/detach/detach.go index f0c735cb1..41e9cf194 100644 --- a/internal/cmd/server/network-interface/detach/detach.go +++ b/internal/cmd/server/network-interface/detach/detach.go @@ -4,9 +4,12 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -15,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -35,7 +37,7 @@ type inputModel struct { Delete *bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "detach", Short: "Detaches a network interface from a server", @@ -51,22 +53,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit server network-interface detach --network-id xxx --server-id yyy --delete`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, *model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = *model.ServerId } else if serverLabel == "" { serverLabel = *model.ServerId @@ -74,42 +76,40 @@ func NewCmd(p *print.Printer) *cobra.Command { // if the delete flag is provided a network interface is detached and deleted if model.Delete != nil && *model.Delete { - networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, *model.NetworkId) + networkLabel, err := iaasUtils.GetNetworkName(ctx, apiClient, model.ProjectId, model.Region, *model.NetworkId) if err != nil { - p.Debug(print.ErrorLevel, "get network name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get network name: %v", err) networkLabel = *model.NetworkId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to detach and delete all network interfaces of network %q from server %q? (This cannot be undone)", networkLabel, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to detach and delete all network interfaces of network %q from server %q? (This cannot be undone)", networkLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } + // Call API req := buildRequestDetachAndDelete(ctx, model, apiClient) err = req.Execute() if err != nil { return fmt.Errorf("detach and delete network interfaces: %w", err) } - p.Info("Detached and deleted all network interfaces of network %q from server %q\n", networkLabel, serverLabel) + params.Printer.Info("Detached and deleted all network interfaces of network %q from server %q\n", networkLabel, serverLabel) return nil } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to detach network interface %q from server %q?", *model.NicId, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to detach network interface %q from server %q?", *model.NicId, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } + // Call API req := buildRequestDetach(ctx, model, apiClient) err = req.Execute() if err != nil { return fmt.Errorf("detach network interface: %w", err) } - p.Info("Detached network interface %q from server %q\n", utils.PtrString(model.NicId), serverLabel) + params.Printer.Info("Detached network interface %q from server %q\n", utils.PtrString(model.NicId), serverLabel) return nil }, @@ -132,10 +132,10 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } // if delete is not provided then network-interface-id is needed @@ -153,22 +153,14 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Delete: deleteValue, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequestDetach(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemoveNicFromServerRequest { - return apiClient.RemoveNicFromServer(ctx, model.ProjectId, *model.ServerId, *model.NicId) + return apiClient.RemoveNicFromServer(ctx, model.ProjectId, model.Region, *model.ServerId, *model.NicId) } func buildRequestDetachAndDelete(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemoveNetworkFromServerRequest { - return apiClient.RemoveNetworkFromServer(ctx, model.ProjectId, *model.ServerId, *model.NetworkId) + return apiClient.RemoveNetworkFromServer(ctx, model.ProjectId, model.Region, *model.ServerId, *model.NetworkId) } diff --git a/internal/cmd/server/network-interface/detach/detach_test.go b/internal/cmd/server/network-interface/detach/detach_test.go index df408bbb8..4946e3e41 100644 --- a/internal/cmd/server/network-interface/detach/detach_test.go +++ b/internal/cmd/server/network-interface/detach/detach_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +14,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -28,7 +30,9 @@ var testNetworkId = uuid.NewString() // contains nic id func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, networkInterfaceIdFlag: testNicId, } @@ -43,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: utils.Ptr(testServerId), NicId: utils.Ptr(testNicId), @@ -54,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequestDetach(mods ...func(request *iaas.ApiRemoveNicFromServerRequest)) iaas.ApiRemoveNicFromServerRequest { - request := testClient.RemoveNicFromServer(testCtx, testProjectId, testServerId, testNicId) + request := testClient.RemoveNicFromServer(testCtx, testProjectId, testRegion, testServerId, testNicId) for _, mod := range mods { mod(&request) } @@ -62,7 +67,7 @@ func fixtureRequestDetach(mods ...func(request *iaas.ApiRemoveNicFromServerReque } func fixtureRequestDetachAndDelete(mods ...func(request *iaas.ApiRemoveNetworkFromServerRequest)) iaas.ApiRemoveNetworkFromServerRequest { - request := testClient.RemoveNetworkFromServer(testCtx, testProjectId, testServerId, testNetworkId) + request := testClient.RemoveNetworkFromServer(testCtx, testProjectId, testRegion, testServerId, testNetworkId) for _, mod := range mods { mod(&request) } @@ -72,6 +77,7 @@ func fixtureRequestDetachAndDelete(mods ...func(request *iaas.ApiRemoveNetworkFr func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -90,21 +96,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -198,54 +204,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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.ValidateFlagGroups() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flag groups: %v", err) - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/network-interface/list/list.go b/internal/cmd/server/network-interface/list/list.go index eb3210cf4..c154f89b3 100644 --- a/internal/cmd/server/network-interface/list/list.go +++ b/internal/cmd/server/network-interface/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -27,11 +28,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all attached network interfaces of a server", @@ -51,15 +52,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit server network-interface list --server-id xxx --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -72,14 +73,14 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } - p.Info("No attached network interfaces found for server %q\n", serverLabel) + params.Printer.Info("No attached network interfaces found for server %q\n", serverLabel) return nil } @@ -89,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, *model.ServerId, items) + return outputResult(params.Printer, model.OutputFormat, model.ServerId, items) }, } configureFlags(cmd) @@ -104,7 +105,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -120,45 +121,20 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListServerNicsRequest { - return apiClient.ListServerNics(ctx, model.ProjectId, *model.ServerId) +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListServerNICsRequest { + return apiClient.ListServerNICs(ctx, model.ProjectId, model.Region, model.ServerId) } func outputResult(p *print.Printer, outputFormat, serverId string, serverNics []iaas.NIC) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(serverNics, "", " ") - if err != nil { - return fmt.Errorf("marshal server network interfaces: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(serverNics, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server network interfaces: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, serverNics, func() error { table := tables.NewTable() table.SetHeader("NIC ID", "SERVER ID") @@ -170,5 +146,5 @@ func outputResult(p *print.Printer, outputFormat, serverId string, serverNics [] p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/server/network-interface/list/list_test.go b/internal/cmd/server/network-interface/list/list_test.go index f627b1d19..27f411166 100644 --- a/internal/cmd/server/network-interface/list/list_test.go +++ b/internal/cmd/server/network-interface/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +17,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -25,9 +30,11 @@ var testServerId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", - serverIdFlag: testServerId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + limitFlag: "10", + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -40,9 +47,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, } for _, mod := range mods { mod(model) @@ -50,8 +58,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { return model } -func fixtureRequest(mods ...func(request *iaas.ApiListServerNicsRequest)) iaas.ApiListServerNicsRequest { - request := testClient.ListServerNics(testCtx, testProjectId, testServerId) +func fixtureRequest(mods ...func(request *iaas.ApiListServerNICsRequest)) iaas.ApiListServerNICsRequest { + request := testClient.ListServerNICs(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -61,6 +69,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListServerNicsRequest)) iaas.A func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -79,21 +88,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,46 +145,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -184,7 +154,7 @@ func TestBuildRequest(t *testing.T) { tests := []struct { description string model *inputModel - expectedRequest iaas.ApiListServerNicsRequest + expectedRequest iaas.ApiListServerNICsRequest }{ { description: "base", @@ -235,7 +205,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverId, tt.args.serverNics); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/network-interface/network-interface.go b/internal/cmd/server/network-interface/network-interface.go index 703e48eba..2496def12 100644 --- a/internal/cmd/server/network-interface/network-interface.go +++ b/internal/cmd/server/network-interface/network-interface.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/network-interface/detach" "github.com/stackitcloud/stackit-cli/internal/cmd/server/network-interface/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "network-interface", Short: "Allows attaching/detaching network interfaces to servers", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(attach.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(detach.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(attach.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(detach.NewCmd(params)) } diff --git a/internal/cmd/server/os-update/create/create.go b/internal/cmd/server/os-update/create/create.go index 71845bc2a..1d1ad8962 100644 --- a/internal/cmd/server/os-update/create/create.go +++ b/internal/cmd/server/os-update/create/create.go @@ -2,17 +2,18 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverosupdate/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -34,7 +35,7 @@ type inputModel struct { MaintenanceWindow int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Server os-update.", @@ -48,37 +49,35 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a Server os-update with name "myupdate" and maintenance window for 13 o'clock.`, `$ stackit server os-update create --server-id xxx --maintenance-window=13`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a os-update for server %s?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a os-update for server %s?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -91,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Server os-update: %w", err) } - return outputResult(p, model.OutputFormat, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp) }, } configureFlags(cmd) @@ -103,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64P(maintenanceWindowFlag, "m", defaultMaintenanceWindow, "Maintenance window (in hours, 1-24)") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -115,15 +114,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { MaintenanceWindow: flags.FlagWithDefaultToInt64Value(p, cmd, maintenanceWindowFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -137,25 +128,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdat } func outputResult(p *print.Printer, outputFormat, serverLabel string, resp serverupdate.Update) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal server os-update: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server os-update: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Triggered creation of server os-update for server %s. Update ID: %s\n", serverLabel, utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/server/os-update/create/create_test.go b/internal/cmd/server/os-update/create/create_test.go index 9dd635e70..c95f62de3 100644 --- a/internal/cmd/server/os-update/create/create_test.go +++ b/internal/cmd/server/os-update/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -77,6 +80,7 @@ func fixturePayload(mods ...func(payload *serverupdate.CreateUpdatePayload)) ser func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -128,46 +132,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -226,7 +191,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/os-update/describe/describe.go b/internal/cmd/server/os-update/describe/describe.go index 7260131ff..8d349791b 100644 --- a/internal/cmd/server/os-update/describe/describe.go +++ b/internal/cmd/server/os-update/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverosupdate/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" ) const ( @@ -30,7 +31,7 @@ type inputModel struct { UpdateId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", updateIdArg), Short: "Shows details of a Server os-update", @@ -46,12 +47,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +64,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server os-update: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -91,15 +92,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu UpdateId: updateId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -109,24 +102,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdat } func outputResult(p *print.Printer, outputFormat string, update serverupdate.Update) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(update, "", " ") - if err != nil { - return fmt.Errorf("marshal server update: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(update, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server update: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, update, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(update.Id)) table.AddSeparator() @@ -149,5 +125,5 @@ func outputResult(p *print.Printer, outputFormat string, update serverupdate.Upd } return nil - } + }) } diff --git a/internal/cmd/server/os-update/describe/describe_test.go b/internal/cmd/server/os-update/describe/describe_test.go index 741bbe6dc..646b5ac87 100644 --- a/internal/cmd/server/os-update/describe/describe_test.go +++ b/internal/cmd/server/os-update/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -132,54 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -231,7 +187,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.update); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/os-update/disable/disable.go b/internal/cmd/server/os-update/disable/disable.go index f0270b0cd..c2a308710 100644 --- a/internal/cmd/server/os-update/disable/disable.go +++ b/internal/cmd/server/os-update/disable/disable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -27,7 +29,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "disable", Short: "Disables server os-update service", @@ -38,36 +40,34 @@ func NewCmd(p *print.Printer) *cobra.Command { `Disable os-update functionality for your server.`, "$ stackit server os-update disable --server-id=zzz"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to disable the os-update service for server %s?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to disable the os-update service for server %s?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -77,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("disable server os-update service: %w", err) } - p.Info("Disabled Server os-update service for server %s\n", serverLabel) + params.Printer.Info("Disabled Server os-update service for server %s\n", serverLabel) return nil }, } @@ -92,7 +92,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -103,15 +103,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/os-update/disable/disable_test.go b/internal/cmd/server/os-update/disable/disable_test.go index 566300e33..b7d28e23c 100644 --- a/internal/cmd/server/os-update/disable/disable_test.go +++ b/internal/cmd/server/os-update/disable/disable_test.go @@ -5,12 +5,11 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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/serverupdate" ) @@ -29,6 +28,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st flagValues := map[string]string{ globalflags.ProjectIdFlag: testProjectId, globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -62,17 +62,23 @@ func fixtureRequest(mods ...func(request *serverupdate.ApiDisableServiceResource func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel }{ { - description: "base", - flagValues: fixtureFlagValues(), - isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.ServerId = "" + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "server id flag is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" }), + isValid: false, }, { description: "no values", @@ -104,46 +110,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/os-update/enable/enable.go b/internal/cmd/server/os-update/enable/enable.go index c255c778e..0d4630c45 100644 --- a/internal/cmd/server/os-update/enable/enable.go +++ b/internal/cmd/server/os-update/enable/enable.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -28,7 +30,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "enable", Short: "Enables Server os-update service", @@ -39,36 +41,34 @@ func NewCmd(p *print.Printer) *cobra.Command { `Enable os-update functionality for your server`, "$ stackit server os-update enable --server-id=zzz"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to enable the server os-update service for server %s?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to enable the server os-update service for server %s?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -80,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } } - p.Info("Enabled os-update service for server %s\n", serverLabel) + params.Printer.Info("Enabled os-update service for server %s\n", serverLabel) return nil }, } @@ -95,7 +95,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -106,15 +106,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/os-update/enable/enable_test.go b/internal/cmd/server/os-update/enable/enable_test.go index d35251a70..4ebed0b3a 100644 --- a/internal/cmd/server/os-update/enable/enable_test.go +++ b/internal/cmd/server/os-update/enable/enable_test.go @@ -5,12 +5,11 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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/serverupdate" ) @@ -29,6 +28,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st flagValues := map[string]string{ globalflags.ProjectIdFlag: testProjectId, globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -62,17 +62,23 @@ func fixtureRequest(mods ...func(request *serverupdate.ApiEnableServiceResourceR func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel }{ { - description: "base", - flagValues: fixtureFlagValues(), - isValid: true, - expectedModel: fixtureInputModel(func(model *inputModel) { - model.ServerId = "" + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "server id flag is missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" }), + isValid: false, }, { description: "no values", @@ -104,46 +110,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/os-update/list/list.go b/internal/cmd/server/os-update/list/list.go index e1435badc..7ff61504e 100644 --- a/internal/cmd/server/os-update/list/list.go +++ b/internal/cmd/server/os-update/list/list.go @@ -2,10 +2,11 @@ package list import ( "context" - "encoding/json" "fmt" "strconv" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -18,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" ) @@ -34,7 +34,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all server os-updates", @@ -48,15 +48,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all os-updates for a server with ID "xxx" in JSON format`, "$ stackit server os-update list --server-id xxx --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -71,15 +71,15 @@ func NewCmd(p *print.Printer) *cobra.Command { if len(updates) == 0 { serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - p.Info("No os-updates found for server %s\n", serverLabel) + params.Printer.Info("No os-updates found for server %s\n", serverLabel) return nil } @@ -87,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(updates) > int(*model.Limit) { updates = updates[:*model.Limit] } - return outputResult(p, model.OutputFormat, updates) + return outputResult(params.Printer, model.OutputFormat, updates) }, } configureFlags(cmd) @@ -102,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -122,15 +122,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -140,24 +132,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdat } func outputResult(p *print.Printer, outputFormat string, updates []serverupdate.Update) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(updates, "", " ") - if err != nil { - return fmt.Errorf("marshal server os-update list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(updates, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server os-update list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, updates, func() error { table := tables.NewTable() table.SetHeader("ID", "STATUS", "INSTALLED UPDATES", "FAILED UPDATES", "START DATE", "END DATE") for i := range updates { @@ -189,5 +164,5 @@ func outputResult(p *print.Printer, outputFormat string, updates []serverupdate. return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/os-update/list/list_test.go b/internal/cmd/server/os-update/list/list_test.go index 36205f88d..99cf70484 100644 --- a/internal/cmd/server/os-update/list/list_test.go +++ b/internal/cmd/server/os-update/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -65,6 +68,7 @@ func fixtureRequest(mods ...func(request *serverupdate.ApiListUpdatesRequest)) s func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -119,46 +123,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -208,7 +173,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.updates); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/os-update/os-update.go b/internal/cmd/server/os-update/os-update.go index a9d3ec8fc..53abb7893 100644 --- a/internal/cmd/server/os-update/os-update.go +++ b/internal/cmd/server/os-update/os-update.go @@ -8,13 +8,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/list" "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/schedule" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "os-update", Short: "Provides functionality for managed server updates", @@ -22,15 +22,15 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(enable.NewCmd(p)) - cmd.AddCommand(disable.NewCmd(p)) - cmd.AddCommand(schedule.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(enable.NewCmd(params)) + cmd.AddCommand(disable.NewCmd(params)) + cmd.AddCommand(schedule.NewCmd(params)) } diff --git a/internal/cmd/server/os-update/schedule/create/create.go b/internal/cmd/server/os-update/schedule/create/create.go index 2f30e876f..421a173c6 100644 --- a/internal/cmd/server/os-update/schedule/create/create.go +++ b/internal/cmd/server/os-update/schedule/create/create.go @@ -2,10 +2,10 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -43,7 +43,7 @@ type inputModel struct { MaintenanceWindow int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a Server os-update Schedule", @@ -57,37 +57,35 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a Server os-update Schedule with name "myschedule" and maintenance window for 14 o'clock`, `$ stackit server os-update schedule create --server-id xxx --name=myschedule --maintenance-window=14`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a os-update Schedule for server %s?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a os-update Schedule for server %s?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -100,7 +98,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create Server os-update Schedule: %w", err) } - return outputResult(p, model.OutputFormat, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp) }, } configureFlags(cmd) @@ -118,7 +116,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -133,15 +131,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Enabled: flags.FlagToBoolValue(p, cmd, enabledFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -157,25 +147,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdat } func outputResult(p *print.Printer, outputFormat, serverLabel string, resp serverupdate.UpdateSchedule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal server os-update schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server os-update schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Outputf("Created server os-update schedule for server %s. os-update Schedule ID: %s\n", serverLabel, utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/server/os-update/schedule/create/create_test.go b/internal/cmd/server/os-update/schedule/create/create_test.go index 7d084516e..1e29f4f30 100644 --- a/internal/cmd/server/os-update/schedule/create/create_test.go +++ b/internal/cmd/server/os-update/schedule/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -86,6 +89,7 @@ func fixturePayload(mods ...func(payload *serverupdate.CreateUpdateSchedulePaylo func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string aclValues []string isValid bool @@ -135,46 +139,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -233,7 +198,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/os-update/schedule/delete/delete.go b/internal/cmd/server/os-update/schedule/delete/delete.go index 855690fa4..b26084c9a 100644 --- a/internal/cmd/server/os-update/schedule/delete/delete.go +++ b/internal/cmd/server/os-update/schedule/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -27,7 +29,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", scheduleIdArg), Short: "Deletes a Server os-update Schedule", @@ -40,23 +42,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete server os-update schedule %q? (This cannot be undone)", model.ScheduleId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete server os-update schedule %q? (This cannot be undone)", model.ScheduleId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete Server os-update Schedule: %w", err) } - p.Info("Deleted server os-update schedule %q\n", model.ScheduleId) + params.Printer.Info("Deleted server os-update schedule %q\n", model.ScheduleId) return nil }, } @@ -95,15 +95,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/server/os-update/schedule/delete/delete_test.go b/internal/cmd/server/os-update/schedule/delete/delete_test.go index 040aa61f7..99b0aaafb 100644 --- a/internal/cmd/server/os-update/schedule/delete/delete_test.go +++ b/internal/cmd/server/os-update/schedule/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -132,54 +132,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/os-update/schedule/describe/describe.go b/internal/cmd/server/os-update/schedule/describe/describe.go index 60aa9431c..0e810343d 100644 --- a/internal/cmd/server/os-update/schedule/describe/describe.go +++ b/internal/cmd/server/os-update/schedule/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverosupdate/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" ) const ( @@ -30,7 +31,7 @@ type inputModel struct { ScheduleId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", scheduleIdArg), Short: "Shows details of a Server os-update Schedule", @@ -46,12 +47,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -63,7 +64,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read server os-update schedule: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -91,15 +92,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ScheduleId: scheduleId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -109,24 +102,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdat } func outputResult(p *print.Printer, outputFormat string, schedule serverupdate.UpdateSchedule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(schedule, "", " ") - if err != nil { - return fmt.Errorf("marshal server os-update schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(schedule, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server os-update schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, schedule, func() error { table := tables.NewTable() table.AddRow("SCHEDULE ID", utils.PtrString(schedule.Id)) table.AddSeparator() @@ -145,5 +121,5 @@ func outputResult(p *print.Printer, outputFormat string, schedule serverupdate.U } return nil - } + }) } diff --git a/internal/cmd/server/os-update/schedule/describe/describe_test.go b/internal/cmd/server/os-update/schedule/describe/describe_test.go index 5928e712b..0904ac5b5 100644 --- a/internal/cmd/server/os-update/schedule/describe/describe_test.go +++ b/internal/cmd/server/os-update/schedule/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -132,54 +135,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -231,7 +187,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.schedule); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/os-update/schedule/list/list.go b/internal/cmd/server/os-update/schedule/list/list.go index 562b30665..c2d6153f6 100644 --- a/internal/cmd/server/os-update/schedule/list/list.go +++ b/internal/cmd/server/os-update/schedule/list/list.go @@ -2,23 +2,25 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - iaasClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverosupdate/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" ) const ( @@ -32,7 +34,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all server os-update schedules", @@ -46,15 +48,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all os-update schedules for a server with ID "xxx" in JSON format`, "$ stackit server os-update schedule list --server-id xxx --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -69,15 +71,15 @@ func NewCmd(p *print.Printer) *cobra.Command { if len(schedules) == 0 { serverLabel := model.ServerId // Get server name - if iaasApiClient, err := iaasClient.ConfigureClient(p); err == nil { - serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.ServerId) + if iaasApiClient, err := iaasClient.ConfigureClient(params.Printer, params.CliVersion); err == nil { + serverName, err := iaasUtils.GetServerName(ctx, iaasApiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) } else if serverName != "" { serverLabel = serverName } } - p.Info("No os-update schedules found for server %s\n", serverLabel) + params.Printer.Info("No os-update schedules found for server %s\n", serverLabel) return nil } @@ -85,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if model.Limit != nil && len(schedules) > int(*model.Limit) { schedules = schedules[:*model.Limit] } - return outputResult(p, model.OutputFormat, schedules) + return outputResult(params.Printer, model.OutputFormat, schedules) }, } configureFlags(cmd) @@ -100,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -120,15 +122,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -138,24 +132,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdat } func outputResult(p *print.Printer, outputFormat string, schedules []serverupdate.UpdateSchedule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(schedules, "", " ") - if err != nil { - return fmt.Errorf("marshal Server os-update Schedules list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(schedules, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal Server os-update Schedules list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, schedules, func() error { table := tables.NewTable() table.SetHeader("SCHEDULE ID", "SCHEDULE NAME", "ENABLED", "RRULE", "MAINTENANCE WINDOW") for i := range schedules { @@ -173,5 +150,5 @@ func outputResult(p *print.Printer, outputFormat string, schedules []serverupdat return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/os-update/schedule/list/list_test.go b/internal/cmd/server/os-update/schedule/list/list_test.go index d29d3dd5f..5f4fceebc 100644 --- a/internal/cmd/server/os-update/schedule/list/list_test.go +++ b/internal/cmd/server/os-update/schedule/list/list_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -65,6 +68,7 @@ func fixtureRequest(mods ...func(request *serverupdate.ApiListUpdateSchedulesReq func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -119,46 +123,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -208,7 +173,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.schedules); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/os-update/schedule/schedule.go b/internal/cmd/server/os-update/schedule/schedule.go index 9f051ced0..d3ccd6b63 100644 --- a/internal/cmd/server/os-update/schedule/schedule.go +++ b/internal/cmd/server/os-update/schedule/schedule.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/schedule/list" "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/schedule/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "schedule", Short: "Provides functionality for Server os-update Schedule", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(del.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(del.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/server/os-update/schedule/update/update.go b/internal/cmd/server/os-update/schedule/update/update.go index e698f21f7..f14429e2c 100644 --- a/internal/cmd/server/os-update/schedule/update/update.go +++ b/internal/cmd/server/os-update/schedule/update/update.go @@ -2,11 +2,13 @@ package update import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverosupdate/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" ) const ( @@ -43,7 +44,7 @@ type inputModel struct { MaintenanceWindow *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", scheduleIdArg), Short: "Updates a Server os-update Schedule", @@ -57,29 +58,27 @@ func NewCmd(p *print.Printer) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } currentSchedule, err := apiClient.GetUpdateScheduleExecute(ctx, model.ProjectId, model.ServerId, model.ScheduleId, model.Region) if err != nil { - p.Debug(print.ErrorLevel, "get current server os-update schedule: %v", err) + params.Printer.Debug(print.ErrorLevel, "get current server os-update schedule: %v", err) return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update Server os-update Schedule %q?", model.ScheduleId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update Server os-update Schedule %q?", model.ScheduleId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -92,7 +91,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update Server os-update Schedule: %w", err) } - return outputResult(p, model.OutputFormat, *resp) + return outputResult(params.Printer, model.OutputFormat, *resp) }, } configureFlags(cmd) @@ -129,15 +128,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Enabled: flags.FlagToBoolPointer(p, cmd, enabledFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -167,25 +158,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdat } func outputResult(p *print.Printer, outputFormat string, resp serverupdate.UpdateSchedule) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(resp, "", " ") - if err != nil { - return fmt.Errorf("marshal update server os-update schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal update server os-update schedule: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, resp, func() error { p.Info("Updated server os-update schedule %s\n", utils.PtrString(resp.Id)) return nil - } + }) } diff --git a/internal/cmd/server/os-update/schedule/update/update_test.go b/internal/cmd/server/os-update/schedule/update/update_test.go index 63ab21239..e3cf7c7ab 100644 --- a/internal/cmd/server/os-update/schedule/update/update_test.go +++ b/internal/cmd/server/os-update/schedule/update/update_test.go @@ -5,6 +5,8 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -173,7 +175,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -285,7 +287,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.resp); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/public-ip/attach/attach.go b/internal/cmd/server/public-ip/attach/attach.go index 848a913b4..af300bb19 100644 --- a/internal/cmd/server/public-ip/attach/attach.go +++ b/internal/cmd/server/public-ip/attach/attach.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -25,11 +28,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string PublicIpId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("attach %s", publicIpIdArg), Short: "Attaches a public IP to a server", @@ -42,39 +45,37 @@ func NewCmd(p *print.Printer) *cobra.Command { )), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.PublicIpId) + publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId) if err != nil { - p.Debug(print.ErrorLevel, "get public ip name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get public ip name: %v", err) publicIpLabel = model.PublicIpId } else if publicIpLabel == "" { publicIpLabel = model.PublicIpId } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to attach public IP %q to server %q?", publicIpLabel, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to attach public IP %q to server %q?", publicIpLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("attach server to public ip: %w", err) } - p.Info("Attached public IP %q to server %q\n", publicIpLabel, serverLabel) + params.Printer.Info("Attached public IP %q to server %q\n", publicIpLabel, serverLabel) return nil }, } @@ -108,22 +109,14 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), PublicIpId: volumeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddPublicIpToServerRequest { - return apiClient.AddPublicIpToServer(ctx, model.ProjectId, *model.ServerId, model.PublicIpId) + return apiClient.AddPublicIpToServer(ctx, model.ProjectId, model.Region, model.ServerId, model.PublicIpId) } diff --git a/internal/cmd/server/public-ip/attach/attach_test.go b/internal/cmd/server/public-ip/attach/attach_test.go index b8a80d6b2..11e894f33 100644 --- a/internal/cmd/server/public-ip/attach/attach_test.go +++ b/internal/cmd/server/public-ip/attach/attach_test.go @@ -4,17 +4,20 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +39,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - serverIdFlag: testServerId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -50,8 +55,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, PublicIpId: testPublicIpId, } for _, mod := range mods { @@ -61,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiAddPublicIpToServerRequest)) iaas.ApiAddPublicIpToServerRequest { - request := testClient.AddPublicIpToServer(testCtx, testProjectId, testServerId, testPublicIpId) + request := testClient.AddPublicIpToServer(testCtx, testProjectId, testRegion, testServerId, testPublicIpId) for _, mod := range mods { mod(&request) } @@ -93,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -101,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -109,7 +115,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -147,7 +153,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/server/public-ip/detach/detach.go b/internal/cmd/server/public-ip/detach/detach.go index 809b76759..4e53bdf0c 100644 --- a/internal/cmd/server/public-ip/detach/detach.go +++ b/internal/cmd/server/public-ip/detach/detach.go @@ -4,7 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -25,11 +28,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string PublicIpId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("detach %s", publicIpIdArg), Short: "Detaches a public IP from a server", @@ -43,39 +46,37 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.PublicIpId) + publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId) if err != nil { - p.Debug(print.ErrorLevel, "get public ip: %v", err) + params.Printer.Debug(print.ErrorLevel, "get public ip: %v", err) publicIpLabel = model.PublicIpId } else if publicIpLabel == "" { publicIpLabel = model.PublicIpId } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to detach public IP %q from server %q?", publicIpLabel, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to detach public IP %q from server %q?", publicIpLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("detach public ip from server: %w", err) } - p.Info("Detached public IP %q from server %q\n", publicIpLabel, serverLabel) + params.Printer.Info("Detached public IP %q from server %q\n", publicIpLabel, serverLabel) return nil }, @@ -109,22 +110,14 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), PublicIpId: publicIpId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemovePublicIpFromServerRequest { - return apiClient.RemovePublicIpFromServer(ctx, model.ProjectId, *model.ServerId, model.PublicIpId) + return apiClient.RemovePublicIpFromServer(ctx, model.ProjectId, model.Region, model.ServerId, model.PublicIpId) } diff --git a/internal/cmd/server/public-ip/detach/detach_test.go b/internal/cmd/server/public-ip/detach/detach_test.go index b7ed5c286..99251eae0 100644 --- a/internal/cmd/server/public-ip/detach/detach_test.go +++ b/internal/cmd/server/public-ip/detach/detach_test.go @@ -4,9 +4,10 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -14,7 +15,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +39,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - serverIdFlag: testServerId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -50,8 +54,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, PublicIpId: testPublicIpId, } for _, mod := range mods { @@ -61,7 +66,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiRemovePublicIpFromServerRequest)) iaas.ApiRemovePublicIpFromServerRequest { - request := testClient.RemovePublicIpFromServer(testCtx, testProjectId, testServerId, testPublicIpId) + request := testClient.RemovePublicIpFromServer(testCtx, testProjectId, testRegion, testServerId, testPublicIpId) for _, mod := range mods { mod(&request) } @@ -92,7 +97,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -100,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -108,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -146,7 +151,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/server/public-ip/public_ip.go b/internal/cmd/server/public-ip/public_ip.go index 0bfafbcbd..db04a0a67 100644 --- a/internal/cmd/server/public-ip/public_ip.go +++ b/internal/cmd/server/public-ip/public_ip.go @@ -4,13 +4,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/public-ip/attach" "github.com/stackitcloud/stackit-cli/internal/cmd/server/public-ip/detach" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "public-ip", Short: "Allows attaching/detaching public IPs to servers", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(attach.NewCmd(p)) - cmd.AddCommand(detach.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(attach.NewCmd(params)) + cmd.AddCommand(detach.NewCmd(params)) } diff --git a/internal/cmd/server/reboot/reboot.go b/internal/cmd/server/reboot/reboot.go index 80aed47b3..fcc57bc08 100644 --- a/internal/cmd/server/reboot/reboot.go +++ b/internal/cmd/server/reboot/reboot.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -32,7 +35,7 @@ type inputModel struct { HardReboot bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("reboot %s", serverIdArg), Short: "Reboots a server", @@ -50,30 +53,28 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to reboot server %q?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to reboot server %q?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -83,7 +84,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("server reboot: %w", err) } - p.Info("Server %q rebooted\n", serverLabel) + params.Printer.Info("Server %q rebooted\n", serverLabel) return nil }, @@ -110,20 +111,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu HardReboot: flags.FlagToBoolValue(p, cmd, hardRebootFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRebootServerRequest { - req := apiClient.RebootServer(ctx, model.ProjectId, model.ServerId) + req := apiClient.RebootServer(ctx, model.ProjectId, model.Region, model.ServerId) // if hard reboot is set the action must be set (soft is default) if model.HardReboot { req = req.Action(hardRebootAction) diff --git a/internal/cmd/server/reboot/reboot_test.go b/internal/cmd/server/reboot/reboot_test.go index c4444fa85..1a05016e1 100644 --- a/internal/cmd/server/reboot/reboot_test.go +++ b/internal/cmd/server/reboot/reboot_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + hardRebootFlag: "false", } for _, mod := range mods { @@ -48,6 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, HardReboot: false, @@ -59,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiRebootServerRequest)) iaas.ApiRebootServerRequest { - request := testClient.RebootServer(testCtx, testProjectId, testServerId) + request := testClient.RebootServer(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -97,7 +102,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -105,7 +110,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -129,54 +134,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -198,7 +156,7 @@ func TestBuildRequest(t *testing.T) { model.HardReboot = true }), expectedRequest: fixtureRequest(func(request *iaas.ApiRebootServerRequest) { - *request = request.Action("hard") + *request = (*request).Action("hard") }), }, } diff --git a/internal/cmd/server/rescue/rescue.go b/internal/cmd/server/rescue/rescue.go index 81eabcab2..9dbd1aa73 100644 --- a/internal/cmd/server/rescue/rescue.go +++ b/internal/cmd/server/rescue/rescue.go @@ -4,6 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,8 +19,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -32,7 +35,7 @@ type inputModel struct { ImageId *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("rescue %s", serverIdArg), Short: "Rescues an existing server", @@ -46,31 +49,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to rescue server %q?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to rescue server %q?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -82,20 +83,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Rescuing server") - _, err = wait.RescueServerWaitHandler(ctx, apiClient, model.ProjectId, model.ServerId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Rescuing server", func() error { + _, err = wait.RescueServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for server rescuing: %w", err) } - s.Stop() } operationState := "Rescued" if model.Async { operationState = "Triggered rescue of" } - p.Info("%s server %q. Image %q is used as temporary boot image\n", operationState, serverLabel, utils.PtrString(model.ImageId)) + params.Printer.Info("%s server %q. Image %q is used as temporary boot image\n", operationState, serverLabel, utils.PtrString(model.ImageId)) return nil }, @@ -125,20 +126,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ImageId: flags.FlagToStringPointer(p, cmd, imageIdFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRescueServerRequest { - req := apiClient.RescueServer(ctx, model.ProjectId, model.ServerId) + req := apiClient.RescueServer(ctx, model.ProjectId, model.Region, model.ServerId) payload := iaas.RescueServerPayload{ Image: model.ImageId, } diff --git a/internal/cmd/server/rescue/rescue_test.go b/internal/cmd/server/rescue/rescue_test.go index 9d17daf78..b7ec93e90 100644 --- a/internal/cmd/server/rescue/rescue_test.go +++ b/internal/cmd/server/rescue/rescue_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +14,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +38,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - imageIdFlag: testImageId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + imageIdFlag: testImageId, } for _, mod := range mods { mod(flagValues) @@ -50,6 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, ImageId: utils.Ptr(testImageId), @@ -61,7 +66,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiRescueServerRequest)) iaas.ApiRescueServerRequest { - request := testClient.RescueServer(testCtx, testProjectId, testServerId) + request := testClient.RescueServer(testCtx, testProjectId, testRegion, testServerId) request = request.RescueServerPayload(iaas.RescueServerPayload{ Image: utils.Ptr(testImageId), }) @@ -104,7 +109,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +117,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +125,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -144,54 +149,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/resize/resize.go b/internal/cmd/server/resize/resize.go index d0112b723..582341781 100644 --- a/internal/cmd/server/resize/resize.go +++ b/internal/cmd/server/resize/resize.go @@ -4,6 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,8 +19,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -32,7 +35,7 @@ type inputModel struct { MachineType *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("resize %s", serverIdArg), Short: "Resizes the server to the given machine type", @@ -46,31 +49,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to resize server %q to machine type %q?", serverLabel, *model.MachineType) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to resize server %q to machine type %q?", serverLabel, *model.MachineType) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -82,20 +83,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Resizing server") - _, err = wait.ResizeServerWaitHandler(ctx, apiClient, model.ProjectId, model.ServerId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Resizing server", func() error { + _, err = wait.ResizeServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for server resizing: %w", err) } - s.Stop() } operationState := "Resized" if model.Async { operationState = "Triggered resize of" } - p.Info("%s server %q\n", operationState, serverLabel) + params.Printer.Info("%s server %q\n", operationState, serverLabel) return nil }, @@ -105,7 +106,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().String(machineTypeFlag, "", "Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/stackit/en/virtual-machine-flavors-75137231.html") + cmd.Flags().String(machineTypeFlag, "", "Name of the type of the machine for the server. Possible values are documented in https://docs.stackit.cloud/products/compute-engine/server/basics/machine-types/") err := flags.MarkFlagsRequired(cmd, machineTypeFlag) cobra.CheckErr(err) @@ -125,20 +126,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu MachineType: flags.FlagToStringPointer(p, cmd, machineTypeFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiResizeServerRequest { - req := apiClient.ResizeServer(ctx, model.ProjectId, model.ServerId) + req := apiClient.ResizeServer(ctx, model.ProjectId, model.Region, model.ServerId) payload := iaas.ResizeServerPayload{ MachineType: model.MachineType, } diff --git a/internal/cmd/server/resize/resize_test.go b/internal/cmd/server/resize/resize_test.go index a93d8fdc1..3ba39088f 100644 --- a/internal/cmd/server/resize/resize_test.go +++ b/internal/cmd/server/resize/resize_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -14,7 +14,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -35,7 +37,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + machineTypeFlag: "t1.2", } for _, mod := range mods { @@ -49,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, MachineType: utils.Ptr("t1.2"), @@ -60,7 +65,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiResizeServerRequest)) iaas.ApiResizeServerRequest { - request := testClient.ResizeServer(testCtx, testProjectId, testServerId) + request := testClient.ResizeServer(testCtx, testProjectId, testRegion, testServerId) request = request.ResizeServerPayload(iaas.ResizeServerPayload{ MachineType: utils.Ptr("t1.2"), }) @@ -103,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -111,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -119,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -143,54 +148,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/security-group/attach/attach.go b/internal/cmd/server/security-group/attach/attach.go new file mode 100644 index 000000000..f6324237c --- /dev/null +++ b/internal/cmd/server/security-group/attach/attach.go @@ -0,0 +1,120 @@ +package attach + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" +) + +const ( + serverIdFlag = "server-id" + securityGroupIdFlag = "security-group-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId string + SecurityGroupId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "attach", + Short: "Attaches a security group to a server", + Long: "Attaches a security group to a server.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Attach a security group with ID "xxx" to a server with ID "yyy"`, + `$ stackit server security-group attach --server-id yyy --security-group-id xxx`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId + } else if serverLabel == "" { + serverLabel = model.ServerId + } + + securityGroupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get security group name: %v", err) + securityGroupLabel = model.SecurityGroupId + } + + prompt := fmt.Sprintf("Are you sure you want to attach security group %q to server %q?", securityGroupLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err := req.Execute(); err != nil { + return fmt.Errorf("attach security group to server: %w", err) + } + + params.Printer.Outputf("Attached security group %q to server %q\n", securityGroupLabel, serverLabel) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + cmd.Flags().Var(flags.UUIDFlag(), securityGroupIdFlag, "Security Group ID") + + err := flags.MarkFlagsRequired(cmd, serverIdFlag, securityGroupIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), + SecurityGroupId: flags.FlagToStringValue(p, cmd, securityGroupIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddSecurityGroupToServerRequest { + req := apiClient.AddSecurityGroupToServer(ctx, model.ProjectId, model.Region, model.ServerId, model.SecurityGroupId) + return req +} diff --git a/internal/cmd/server/security-group/attach/attach_test.go b/internal/cmd/server/security-group/attach/attach_test.go new file mode 100644 index 000000000..9056e3f8e --- /dev/null +++ b/internal/cmd/server/security-group/attach/attach_test.go @@ -0,0 +1,182 @@ +package attach + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testProjectId = uuid.NewString() +var testServerId = uuid.NewString() +var testSecurityGroupId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, + securityGroupIdFlag: testSecurityGroupId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + ServerId: testServerId, + SecurityGroupId: testSecurityGroupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiAddSecurityGroupToServerRequest)) iaas.ApiAddSecurityGroupToServerRequest { + request := testClient.AddSecurityGroupToServer(testCtx, testProjectId, testRegion, testServerId, testSecurityGroupId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "security group id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, securityGroupIdFlag) + }), + isValid: false, + }, + { + description: "security group id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[securityGroupIdFlag] = "" + }), + isValid: false, + }, + { + description: "security group id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[securityGroupIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id flag missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, []string{}, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiAddSecurityGroupToServerRequest + }{ + { + 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/server/security-group/detach/detach.go b/internal/cmd/server/security-group/detach/detach.go new file mode 100644 index 000000000..81fe5b30a --- /dev/null +++ b/internal/cmd/server/security-group/detach/detach.go @@ -0,0 +1,120 @@ +package detach + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" +) + +const ( + serverIdFlag = "server-id" + securityGroupIdFlag = "security-group-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ServerId string + SecurityGroupId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "detach", + Short: "Detaches a security group from a server", + Long: "Detaches a security group from a server.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Detach a security group with ID "xxx" from a server with ID "yyy"`, + `$ stackit server security-group detach --server-id yyy --security-group-id xxx`, + ), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId + } else if serverLabel == "" { + serverLabel = model.ServerId + } + + securityGroupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get security group name: %v", err) + securityGroupLabel = model.SecurityGroupId + } + + prompt := fmt.Sprintf("Are you sure you want to detach security group %q from server %q?", securityGroupLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err := req.Execute(); err != nil { + return fmt.Errorf("detach security group from server: %w", err) + } + + params.Printer.Outputf("Detached security group %q from server %q\n", securityGroupLabel, serverLabel) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID") + cmd.Flags().Var(flags.UUIDFlag(), securityGroupIdFlag, "Security Group ID") + + err := flags.MarkFlagsRequired(cmd, serverIdFlag, securityGroupIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), + SecurityGroupId: flags.FlagToStringValue(p, cmd, securityGroupIdFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemoveSecurityGroupFromServerRequest { + req := apiClient.RemoveSecurityGroupFromServer(ctx, model.ProjectId, model.Region, model.ServerId, model.SecurityGroupId) + return req +} diff --git a/internal/cmd/server/security-group/detach/detach_test.go b/internal/cmd/server/security-group/detach/detach_test.go new file mode 100644 index 000000000..dbf4cc8f3 --- /dev/null +++ b/internal/cmd/server/security-group/detach/detach_test.go @@ -0,0 +1,182 @@ +package detach + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &iaas.APIClient{} +var testProjectId = uuid.NewString() +var testServerId = uuid.NewString() +var testSecurityGroupId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, + securityGroupIdFlag: testSecurityGroupId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + Verbosity: globalflags.VerbosityDefault, + ProjectId: testProjectId, + Region: testRegion, + }, + ServerId: testServerId, + SecurityGroupId: testSecurityGroupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiRemoveSecurityGroupFromServerRequest)) iaas.ApiRemoveSecurityGroupFromServerRequest { + request := testClient.RemoveSecurityGroupFromServer(testCtx, testProjectId, testRegion, testServerId, testSecurityGroupId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "security group id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, securityGroupIdFlag) + }), + isValid: false, + }, + { + description: "security group id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[securityGroupIdFlag] = "" + }), + isValid: false, + }, + { + description: "security group id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[securityGroupIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "server id flag missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serverIdFlag) + }), + isValid: false, + }, + { + description: "server id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "" + }), + isValid: false, + }, + { + description: "server id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[serverIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, []string{}, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiRemoveSecurityGroupFromServerRequest + }{ + { + 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/server/security-group/security-group.go b/internal/cmd/server/security-group/security-group.go new file mode 100644 index 000000000..aed9bfa4a --- /dev/null +++ b/internal/cmd/server/security-group/security-group.go @@ -0,0 +1,28 @@ +package securitygroup + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/server/security-group/attach" + "github.com/stackitcloud/stackit-cli/internal/cmd/server/security-group/detach" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "security-group", + Short: "Allows attaching/detaching security groups to servers", + Long: "Allows attaching/detaching security groups to servers.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(attach.NewCmd(params)) + cmd.AddCommand(detach.NewCmd(params)) +} diff --git a/internal/cmd/server/server.go b/internal/cmd/server/server.go index 209b19e1b..3cb8a5ffc 100644 --- a/internal/cmd/server/server.go +++ b/internal/cmd/server/server.go @@ -17,21 +17,22 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/reboot" "github.com/stackitcloud/stackit-cli/internal/cmd/server/rescue" "github.com/stackitcloud/stackit-cli/internal/cmd/server/resize" + securitygroup "github.com/stackitcloud/stackit-cli/internal/cmd/server/security-group" serviceaccount "github.com/stackitcloud/stackit-cli/internal/cmd/server/service-account" "github.com/stackitcloud/stackit-cli/internal/cmd/server/start" "github.com/stackitcloud/stackit-cli/internal/cmd/server/stop" "github.com/stackitcloud/stackit-cli/internal/cmd/server/unrescue" "github.com/stackitcloud/stackit-cli/internal/cmd/server/update" "github.com/stackitcloud/stackit-cli/internal/cmd/server/volume" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "server", Short: "Provides functionality for servers", @@ -39,31 +40,32 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(backup.NewCmd(p)) - cmd.AddCommand(command.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(publicip.NewCmd(p)) - cmd.AddCommand(serviceaccount.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(volume.NewCmd(p)) - cmd.AddCommand(networkinterface.NewCmd(p)) - cmd.AddCommand(console.NewCmd(p)) - cmd.AddCommand(log.NewCmd(p)) - cmd.AddCommand(start.NewCmd(p)) - cmd.AddCommand(stop.NewCmd(p)) - cmd.AddCommand(reboot.NewCmd(p)) - cmd.AddCommand(deallocate.NewCmd(p)) - cmd.AddCommand(resize.NewCmd(p)) - cmd.AddCommand(rescue.NewCmd(p)) - cmd.AddCommand(unrescue.NewCmd(p)) - cmd.AddCommand(osUpdate.NewCmd(p)) - cmd.AddCommand(machinetype.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(backup.NewCmd(params)) + cmd.AddCommand(command.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(publicip.NewCmd(params)) + cmd.AddCommand(securitygroup.NewCmd(params)) + cmd.AddCommand(serviceaccount.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(volume.NewCmd(params)) + cmd.AddCommand(networkinterface.NewCmd(params)) + cmd.AddCommand(console.NewCmd(params)) + cmd.AddCommand(log.NewCmd(params)) + cmd.AddCommand(start.NewCmd(params)) + cmd.AddCommand(stop.NewCmd(params)) + cmd.AddCommand(reboot.NewCmd(params)) + cmd.AddCommand(deallocate.NewCmd(params)) + cmd.AddCommand(resize.NewCmd(params)) + cmd.AddCommand(rescue.NewCmd(params)) + cmd.AddCommand(unrescue.NewCmd(params)) + cmd.AddCommand(osUpdate.NewCmd(params)) + cmd.AddCommand(machinetype.NewCmd(params)) } diff --git a/internal/cmd/server/service-account/attach/attach.go b/internal/cmd/server/service-account/attach/attach.go index 49f7a1aa4..e7c7ea762 100644 --- a/internal/cmd/server/service-account/attach/attach.go +++ b/internal/cmd/server/service-account/attach/attach.go @@ -2,9 +2,10 @@ package attach import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +15,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -27,11 +27,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string ServiceAccMail string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("attach %s", serviceAccMailArg), Short: "Attach a service account to a server", @@ -45,30 +45,28 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to attach service account %q to server %q?", model.ServiceAccMail, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to attach service account %q to server %q?", model.ServiceAccMail, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("attach service account to server: %w", err) } - return outputResult(p, model.OutputFormat, model.ServiceAccMail, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, model.ServiceAccMail, serverLabel, *resp) }, } configureFlags(cmd) @@ -101,47 +99,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), ServiceAccMail: serviceAccMail, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddServiceAccountToServerRequest { - req := apiClient.AddServiceAccountToServer(ctx, model.ProjectId, *model.ServerId, model.ServiceAccMail) + req := apiClient.AddServiceAccountToServer(ctx, model.ProjectId, model.Region, model.ServerId, model.ServiceAccMail) return req } func outputResult(p *print.Printer, outputFormat, serviceAccMail, serverLabel string, serviceAccounts iaas.ServiceAccountMailListResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(serviceAccounts, "", " ") - if err != nil { - return fmt.Errorf("marshal service account: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(serviceAccounts, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal service account: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, serviceAccounts, func() error { p.Outputf("Attached service account %q to server %q\n", serviceAccMail, serverLabel) return nil - } + }) } diff --git a/internal/cmd/server/service-account/attach/attach_test.go b/internal/cmd/server/service-account/attach/attach_test.go index fddde577b..c4bcdaeaa 100644 --- a/internal/cmd/server/service-account/attach/attach_test.go +++ b/internal/cmd/server/service-account/attach/attach_test.go @@ -4,17 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +40,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - serverIdFlag: testServerId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -50,8 +56,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, ServiceAccMail: testServiceAccount, } for _, mod := range mods { @@ -61,7 +68,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiAddServiceAccountToServerRequest)) iaas.ApiAddServiceAccountToServerRequest { - request := testClient.AddServiceAccountToServer(testCtx, testProjectId, testServerId, testServiceAccount) + request := testClient.AddServiceAccountToServer(testCtx, testProjectId, testRegion, testServerId, testServiceAccount) for _, mod := range mods { mod(&request) } @@ -93,7 +100,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -101,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -109,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -146,7 +153,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -244,7 +251,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serviceAccMail, tt.args.serverLabel, tt.args.serviceAccounts); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/service-account/detach/detach.go b/internal/cmd/server/service-account/detach/detach.go index aa3983c58..07b34db82 100644 --- a/internal/cmd/server/service-account/detach/detach.go +++ b/internal/cmd/server/service-account/detach/detach.go @@ -2,9 +2,10 @@ package detach import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -14,7 +15,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -27,11 +27,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string ServiceAccMail string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("detach %s", serviceAccMailArg), Short: "Detach a service account from a server", @@ -45,30 +45,28 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are your sure you want to detach service account %q from a server %q?", model.ServiceAccMail, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to detach service account %q from a server %q?", model.ServiceAccMail, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("detach service account request: %w", err) } - return outputResult(p, model.OutputFormat, model.ServiceAccMail, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, model.ServiceAccMail, serverLabel, *resp) }, } configureFlags(cmd) @@ -101,47 +99,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), ServiceAccMail: serviceAccMail, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemoveServiceAccountFromServerRequest { - req := apiClient.RemoveServiceAccountFromServer(ctx, model.ProjectId, *model.ServerId, model.ServiceAccMail) + req := apiClient.RemoveServiceAccountFromServer(ctx, model.ProjectId, model.Region, model.ServerId, model.ServiceAccMail) return req } func outputResult(p *print.Printer, outputFormat, serviceAccMail, serverLabel string, service iaas.ServiceAccountMailListResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(service, "", " ") - if err != nil { - return fmt.Errorf("marshal service account: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(service, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal service account: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, service, func() error { p.Outputf("Detached service account %q from server %q\n", serviceAccMail, serverLabel) return nil - } + }) } diff --git a/internal/cmd/server/service-account/detach/detach_test.go b/internal/cmd/server/service-account/detach/detach_test.go index ffee083d4..f421c504d 100644 --- a/internal/cmd/server/service-account/detach/detach_test.go +++ b/internal/cmd/server/service-account/detach/detach_test.go @@ -4,17 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +40,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - serverIdFlag: testServerId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -50,8 +56,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, ServiceAccMail: testServiceAccount, } for _, mod := range mods { @@ -61,7 +68,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiRemoveServiceAccountFromServerRequest)) iaas.ApiRemoveServiceAccountFromServerRequest { - request := testClient.RemoveServiceAccountFromServer(testCtx, testProjectId, testServerId, testServiceAccount) + request := testClient.RemoveServiceAccountFromServer(testCtx, testProjectId, testRegion, testServerId, testServiceAccount) for _, mod := range mods { mod(&request) } @@ -93,7 +100,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -101,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -109,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -146,7 +153,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -244,7 +251,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serviceAccMail, tt.args.serverLabel, tt.args.service); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/service-account/list/list.go b/internal/cmd/server/service-account/list/list.go index d9288a246..a8188b65b 100644 --- a/internal/cmd/server/service-account/list/list.go +++ b/internal/cmd/server/service-account/list/list.go @@ -2,9 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" @@ -14,7 +15,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) @@ -27,10 +27,10 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel Limit *int64 - ServerId *string + ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List all attached service accounts for a server", @@ -50,25 +50,25 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit server service-account list --server-id xxx --output-format json", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverName, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverName, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverName = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverName = model.ServerId } else if serverName == "" { - serverName = *model.ServerId + serverName = model.ServerId } // Call API @@ -79,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } serviceAccounts := *resp.Items if len(serviceAccounts) == 0 { - p.Info("No service accounts found for server %s\n", serverName) + params.Printer.Info("No service accounts found for server %s\n", serverName) return nil } @@ -87,7 +87,7 @@ func NewCmd(p *print.Printer) *cobra.Command { serviceAccounts = serviceAccounts[:int(*model.Limit)] } - return outputResult(p, model.OutputFormat, *model.ServerId, serverName, serviceAccounts) + return outputResult(params.Printer, model.OutputFormat, model.ServerId, serverName, serviceAccounts) }, } configureFlags(cmd) @@ -102,7 +102,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -119,45 +119,20 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { model := inputModel{ GlobalFlagModel: globalFlags, Limit: limit, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), - } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListServerServiceAccountsRequest { - req := apiClient.ListServerServiceAccounts(ctx, model.ProjectId, *model.ServerId) + req := apiClient.ListServerServiceAccounts(ctx, model.ProjectId, model.Region, model.ServerId) return req } func outputResult(p *print.Printer, outputFormat, serverId, serverName string, serviceAccounts []string) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(serviceAccounts, "", " ") - if err != nil { - return fmt.Errorf("marshal service accounts list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(serviceAccounts, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal service accounts list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, serviceAccounts, func() error { table := tables.NewTable() table.SetHeader("SERVER ID", "SERVER NAME", "SERVICE ACCOUNT") for i := range serviceAccounts { @@ -168,5 +143,5 @@ func outputResult(p *print.Printer, outputFormat, serverId, serverName string, s return fmt.Errorf("rednder table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/service-account/list/list_test.go b/internal/cmd/server/service-account/list/list_test.go index 961ba2a0a..a239a9392 100644 --- a/internal/cmd/server/service-account/list/list_test.go +++ b/internal/cmd/server/service-account/list/list_test.go @@ -5,8 +5,11 @@ import ( "strconv" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -15,7 +18,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -27,9 +32,11 @@ var testLimit = int64(10) func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - serverIdFlag: testServerId, - limitFlag: strconv.FormatInt(testLimit, 10), + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, + limitFlag: strconv.FormatInt(testLimit, 10), } for _, mod := range mods { mod(flagValues) @@ -42,8 +49,9 @@ func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, Limit: utils.Ptr(testLimit), } for _, mod := range mods { @@ -53,7 +61,7 @@ func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListServerServiceAccountsRequest)) iaas.ApiListServerServiceAccountsRequest { - request := testClient.ListServerServiceAccounts(testCtx, testProjectId, testServerId) + request := testClient.ListServerServiceAccounts(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -63,6 +71,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListServerServiceAccountsReque func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -81,21 +90,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -148,46 +157,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -248,7 +218,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverId, tt.args.serverName, tt.args.serviceAccounts); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/service-account/service-account.go b/internal/cmd/server/service-account/service-account.go index 0739d29a7..1f61348a4 100644 --- a/internal/cmd/server/service-account/service-account.go +++ b/internal/cmd/server/service-account/service-account.go @@ -3,15 +3,16 @@ package serviceaccount import ( "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/cmd/server/service-account/attach" "github.com/stackitcloud/stackit-cli/internal/cmd/server/service-account/detach" "github.com/stackitcloud/stackit-cli/internal/cmd/server/service-account/list" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "service-account", Short: "Allows attaching/detaching service accounts to servers", @@ -19,12 +20,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: cobra.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(attach.NewCmd(p)) - cmd.AddCommand(detach.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(attach.NewCmd(params)) + cmd.AddCommand(detach.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/server/start/start.go b/internal/cmd/server/start/start.go index 7daec696f..69ea27c96 100644 --- a/internal/cmd/server/start/start.go +++ b/internal/cmd/server/start/start.go @@ -4,6 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +18,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -28,7 +31,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("start %s", serverIdArg), Short: "Starts an existing server or allocates the server if deallocated", @@ -42,20 +45,20 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId @@ -70,20 +73,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Starting server") - _, err = wait.StartServerWaitHandler(ctx, apiClient, model.ProjectId, model.ServerId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Starting server", func() error { + _, err = wait.StartServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for server starting: %w", err) } - s.Stop() } operationState := "Started" if model.Async { operationState = "Triggered start of" } - p.Info("%s server %q\n", operationState, serverLabel) + params.Printer.Info("%s server %q\n", operationState, serverLabel) return nil }, @@ -104,18 +107,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: serverId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiStartServerRequest { - return apiClient.StartServer(ctx, model.ProjectId, model.ServerId) + return apiClient.StartServer(ctx, model.ProjectId, model.Region, model.ServerId) } diff --git a/internal/cmd/server/start/start_test.go b/internal/cmd/server/start/start_test.go index f90b6048d..f5e64b2d2 100644 --- a/internal/cmd/server/start/start_test.go +++ b/internal/cmd/server/start/start_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, } @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiStartServerRequest)) iaas.ApiStartServerRequest { - request := testClient.StartServer(testCtx, testProjectId, testServerId) + request := testClient.StartServer(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -95,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -103,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -127,54 +131,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/stop/stop.go b/internal/cmd/server/stop/stop.go index 1201c778b..3b74699c5 100644 --- a/internal/cmd/server/stop/stop.go +++ b/internal/cmd/server/stop/stop.go @@ -4,6 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +18,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -28,7 +31,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("stop %s", serverIdArg), Short: "Stops an existing server", @@ -42,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to stop server %q?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to stop server %q?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,20 +79,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Stopping server") - _, err = wait.StopServerWaitHandler(ctx, apiClient, model.ProjectId, model.ServerId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Stopping server", func() error { + _, err = wait.StopServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for server stopping: %w", err) } - s.Stop() } operationState := "Stopped" if model.Async { operationState = "Triggered stop of" } - p.Info("%s server %q\n", operationState, serverLabel) + params.Printer.Info("%s server %q\n", operationState, serverLabel) return nil }, @@ -112,18 +113,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: serverId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiStopServerRequest { - return apiClient.StopServer(ctx, model.ProjectId, model.ServerId) + return apiClient.StopServer(ctx, model.ProjectId, model.Region, model.ServerId) } diff --git a/internal/cmd/server/stop/stop_test.go b/internal/cmd/server/stop/stop_test.go index a4e889181..29980a4b9 100644 --- a/internal/cmd/server/stop/stop_test.go +++ b/internal/cmd/server/stop/stop_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, } @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiStopServerRequest)) iaas.ApiStopServerRequest { - request := testClient.StopServer(testCtx, testProjectId, testServerId) + request := testClient.StopServer(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -95,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -103,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -127,54 +131,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/unrescue/unrescue.go b/internal/cmd/server/unrescue/unrescue.go index 5fc2d7c42..0dbc71319 100644 --- a/internal/cmd/server/unrescue/unrescue.go +++ b/internal/cmd/server/unrescue/unrescue.go @@ -4,6 +4,11 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +18,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -28,7 +31,7 @@ type inputModel struct { ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("unrescue %s", serverIdArg), Short: "Unrescues an existing server", @@ -42,31 +45,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to unrescue server %q?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to unrescue server %q?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,20 +79,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Unrescuing server") - _, err = wait.UnrescueServerWaitHandler(ctx, apiClient, model.ProjectId, model.ServerId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Unrescuing server", func() error { + _, err = wait.UnrescueServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for server unrescuing: %w", err) } - s.Stop() } operationState := "Unrescued" if model.Async { operationState = "Triggered unrescue of" } - p.Info("%s server %q\n", operationState, serverLabel) + params.Printer.Info("%s server %q\n", operationState, serverLabel) return nil }, @@ -112,18 +113,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ServerId: serverId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUnrescueServerRequest { - return apiClient.UnrescueServer(ctx, model.ProjectId, model.ServerId) + return apiClient.UnrescueServer(ctx, model.ProjectId, model.Region, model.ServerId) } diff --git a/internal/cmd/server/unrescue/unrescue_test.go b/internal/cmd/server/unrescue/unrescue_test.go index 82f75a370..708fe68d8 100644 --- a/internal/cmd/server/unrescue/unrescue_test.go +++ b/internal/cmd/server/unrescue/unrescue_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, ServerId: testServerId, } @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUnrescueServerRequest)) iaas.ApiUnrescueServerRequest { - request := testClient.UnrescueServer(testCtx, testProjectId, testServerId) + request := testClient.UnrescueServer(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -95,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -103,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -127,54 +131,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/server/update/update.go b/internal/cmd/server/update/update.go index d7e06d95a..570c45019 100644 --- a/internal/cmd/server/update/update.go +++ b/internal/cmd/server/update/update.go @@ -2,12 +2,13 @@ package update import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -34,7 +34,7 @@ type inputModel struct { Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", serverIdArg), Short: "Updates a server", @@ -52,31 +52,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) serverLabel = model.ServerId } else if serverLabel == "" { serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update server %q?", serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update server %q?", serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -86,7 +84,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update server: %w", err) } - return outputResult(p, model.OutputFormat, serverLabel, resp) + return outputResult(params.Printer, model.OutputFormat, serverLabel, resp) }, } configureFlags(cmd) @@ -113,58 +111,24 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateServerRequest { - req := apiClient.UpdateServer(ctx, model.ProjectId, model.ServerId) - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } + req := apiClient.UpdateServer(ctx, model.ProjectId, model.Region, model.ServerId) payload := iaas.UpdateServerPayload{ Name: model.Name, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } return req.UpdateServerPayload(payload) } func outputResult(p *print.Printer, outputFormat, serverLabel string, server *iaas.Server) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(server, "", " ") - if err != nil { - return fmt.Errorf("marshal server: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(server, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, server, func() error { p.Outputf("Updated server %q.\n", serverLabel) return nil - } + }) } diff --git a/internal/cmd/server/update/update_test.go b/internal/cmd/server/update/update_test.go index 5286eb70f..7aea4f1c1 100644 --- a/internal/cmd/server/update/update_test.go +++ b/internal/cmd/server/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,9 +40,11 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - nameFlag: "example-server-name", - projectIdFlag: testProjectId, - labelFlag: "key=value", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + nameFlag: "example-server-name", + labelFlag: "key=value", } for _, mod := range mods { mod(flagValues) @@ -50,6 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, Name: utils.Ptr("example-server-name"), @@ -65,7 +72,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUpdateServerRequest)) iaas.ApiUpdateServerRequest { - request := testClient.UpdateServer(testCtx, testProjectId, testServerId) + request := testClient.UpdateServer(testCtx, testProjectId, testRegion, testServerId) request = request.UpdateServerPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -111,7 +118,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -119,7 +126,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -127,7 +134,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -172,7 +179,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -269,7 +276,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.server); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/volume/attach/attach.go b/internal/cmd/server/volume/attach/attach.go index 61eab4ba0..a1b1695a7 100644 --- a/internal/cmd/server/volume/attach/attach.go +++ b/internal/cmd/server/volume/attach/attach.go @@ -2,13 +2,15 @@ package attach import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -30,12 +31,12 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string VolumeId string DeleteOnTermination *bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("attach %s", volumeIdArg), Short: "Attaches a volume to a server", @@ -53,39 +54,35 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId) if err != nil { - p.Debug(print.ErrorLevel, "get volume name: %v", err) - volumeLabel = model.VolumeId - } else if volumeLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) volumeLabel = model.VolumeId } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to attach volume %q to server %q?", volumeLabel, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to attach volume %q to server %q?", volumeLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -95,7 +92,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("attach server volume: %w", err) } - return outputResult(p, model.OutputFormat, volumeLabel, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, volumeLabel, serverLabel, *resp) }, } configureFlags(cmd) @@ -114,30 +111,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu volumeId := inputArgs[0] globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), DeleteOnTermination: flags.FlagToBoolPointer(p, cmd, deleteOnTerminationFlag), VolumeId: volumeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddVolumeToServerRequest { - req := apiClient.AddVolumeToServer(ctx, model.ProjectId, *model.ServerId, model.VolumeId) + req := apiClient.AddVolumeToServer(ctx, model.ProjectId, model.Region, model.ServerId, model.VolumeId) payload := iaas.AddVolumeToServerPayload{ DeleteOnTermination: model.DeleteOnTermination, } @@ -145,25 +134,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat, volumeLabel, serverLabel string, volume iaas.VolumeAttachment) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(volume, "", " ") - if err != nil { - return fmt.Errorf("marshal server volume: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server volume: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, volume, func() error { p.Outputf("Attached volume %q to server %q\n", volumeLabel, serverLabel) return nil - } + }) } diff --git a/internal/cmd/server/volume/attach/attach_test.go b/internal/cmd/server/volume/attach/attach_test.go index 29dd0ff1f..69bffcfee 100644 --- a/internal/cmd/server/volume/attach/attach_test.go +++ b/internal/cmd/server/volume/attach/attach_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,7 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, deleteOnTerminationFlag: "true", } @@ -51,8 +57,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, VolumeId: testVolumeId, DeleteOnTermination: utils.Ptr(true), } @@ -73,7 +80,7 @@ func fixturePayload(mods ...func(payload *iaas.AddVolumeToServerPayload)) iaas.A } func fixtureRequest(mods ...func(request *iaas.ApiAddVolumeToServerRequest)) iaas.ApiAddVolumeToServerRequest { - request := testClient.AddVolumeToServer(testCtx, testProjectId, testServerId, testVolumeId) + request := testClient.AddVolumeToServer(testCtx, testProjectId, testRegion, testServerId, testVolumeId) request = request.AddVolumeToServerPayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -106,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -114,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -122,7 +129,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -171,7 +178,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -269,7 +276,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.volumeLabel, tt.args.serverLabel, tt.args.volume); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/volume/describe/describe.go b/internal/cmd/server/volume/describe/describe.go index 9336183a5..bd1328de8 100644 --- a/internal/cmd/server/volume/describe/describe.go +++ b/internal/cmd/server/volume/describe/describe.go @@ -2,13 +2,15 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -17,7 +19,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -27,11 +28,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string VolumeId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", volumeIdArg), Short: "Describes a server volume attachment", @@ -53,31 +54,29 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId) if err != nil { - p.Debug(print.ErrorLevel, "get volume name: %v", err) - volumeLabel = model.VolumeId - } else if volumeLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) volumeLabel = model.VolumeId } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } // Call API @@ -87,7 +86,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("describe server volume: %w", err) } - return outputResult(p, model.OutputFormat, serverLabel, volumeLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, serverLabel, volumeLabel, *resp) }, } configureFlags(cmd) @@ -105,51 +104,26 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu volumeId := inputArgs[0] globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), VolumeId: volumeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetAttachedVolumeRequest { - req := apiClient.GetAttachedVolume(ctx, model.ProjectId, *model.ServerId, model.VolumeId) + req := apiClient.GetAttachedVolume(ctx, model.ProjectId, model.Region, model.ServerId, model.VolumeId) return req } func outputResult(p *print.Printer, outputFormat, serverLabel, volumeLabel string, volume iaas.VolumeAttachment) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(volume, "", " ") - if err != nil { - return fmt.Errorf("marshal server volume: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server volume: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, volume, func() error { table := tables.NewTable() table.AddRow("SERVER ID", utils.PtrString(volume.ServerId)) table.AddSeparator() @@ -170,5 +144,5 @@ func outputResult(p *print.Printer, outputFormat, serverLabel, volumeLabel strin return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/volume/describe/describe_test.go b/internal/cmd/server/volume/describe/describe_test.go index c86b57259..3a0a631d9 100644 --- a/internal/cmd/server/volume/describe/describe_test.go +++ b/internal/cmd/server/volume/describe/describe_test.go @@ -4,17 +4,20 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +39,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - serverIdFlag: testServerId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -50,8 +55,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, VolumeId: testVolumeId, } for _, mod := range mods { @@ -61,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetAttachedVolumeRequest)) iaas.ApiGetAttachedVolumeRequest { - request := testClient.GetAttachedVolume(testCtx, testProjectId, testServerId, testVolumeId) + request := testClient.GetAttachedVolume(testCtx, testProjectId, testRegion, testServerId, testVolumeId) for _, mod := range mods { mod(&request) } @@ -93,7 +99,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -101,7 +107,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -109,7 +115,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -147,7 +153,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -245,7 +251,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.volumeLabel, tt.args.volume); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/volume/detach/detach.go b/internal/cmd/server/volume/detach/detach.go index 826f1881c..4e46187ee 100644 --- a/internal/cmd/server/volume/detach/detach.go +++ b/internal/cmd/server/volume/detach/detach.go @@ -4,9 +4,13 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -14,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -25,11 +28,11 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string VolumeId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("detach %s", volumeIdArg), Short: "Detaches a volume from a server", @@ -43,39 +46,35 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId) if err != nil { - p.Debug(print.ErrorLevel, "get volume name: %v", err) - volumeLabel = model.VolumeId - } else if volumeLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) volumeLabel = model.VolumeId } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to detach volume %q from server %q?", volumeLabel, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to detach volume %q from server %q?", volumeLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("detach server volume: %w", err) } - p.Info("Detached volume %q from server %q\n", volumeLabel, serverLabel) + params.Printer.Info("Detached volume %q from server %q\n", volumeLabel, serverLabel) return nil }, @@ -104,28 +103,20 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu volumeId := inputArgs[0] globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), VolumeId: volumeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemoveVolumeFromServerRequest { - req := apiClient.RemoveVolumeFromServer(ctx, model.ProjectId, *model.ServerId, model.VolumeId) + req := apiClient.RemoveVolumeFromServer(ctx, model.ProjectId, model.Region, model.ServerId, model.VolumeId) return req } diff --git a/internal/cmd/server/volume/detach/detach_test.go b/internal/cmd/server/volume/detach/detach_test.go index dd2032277..59d93a547 100644 --- a/internal/cmd/server/volume/detach/detach_test.go +++ b/internal/cmd/server/volume/detach/detach_test.go @@ -4,17 +4,20 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +39,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - serverIdFlag: testServerId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -50,8 +55,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, VolumeId: testVolumeId, } for _, mod := range mods { @@ -61,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiRemoveVolumeFromServerRequest)) iaas.ApiRemoveVolumeFromServerRequest { - request := testClient.RemoveVolumeFromServer(testCtx, testProjectId, testServerId, testVolumeId) + request := testClient.RemoveVolumeFromServer(testCtx, testProjectId, testRegion, testServerId, testVolumeId) for _, mod := range mods { mod(&request) } @@ -92,7 +98,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -100,7 +106,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -108,7 +114,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -146,7 +152,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/server/volume/list/list.go b/internal/cmd/server/volume/list/list.go index dd7509d7f..995303bd7 100644 --- a/internal/cmd/server/volume/list/list.go +++ b/internal/cmd/server/volume/list/list.go @@ -2,13 +2,15 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -17,7 +19,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -26,10 +27,10 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all server volumes", @@ -43,25 +44,25 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all volumes for a server with ID "xxx" in JSON format`, "$ stackit server volumes list --server-id xxx --output-format json"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } // Call API @@ -72,21 +73,22 @@ func NewCmd(p *print.Printer) *cobra.Command { } volumes := *resp.Items if len(volumes) == 0 { - p.Info("No volumes found for server %s\n", serverLabel) + params.Printer.Info("No volumes found for server %s\n", serverLabel) return nil } // get volume names var volumeNames []string for i := range volumes { - volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, *volumes[i].VolumeId) + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, *volumes[i].VolumeId) if err != nil { - p.Debug(print.ErrorLevel, "get volume name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) + volumeLabel = "" } volumeNames = append(volumeNames, volumeLabel) } - return outputResult(p, model.OutputFormat, serverLabel, volumeNames, volumes) + return outputResult(params.Printer, model.OutputFormat, serverLabel, volumeNames, volumes) }, } configureFlags(cmd) @@ -100,53 +102,28 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), - } - - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), } + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListAttachedVolumesRequest { - req := apiClient.ListAttachedVolumes(ctx, model.ProjectId, *model.ServerId) + req := apiClient.ListAttachedVolumes(ctx, model.ProjectId, model.Region, model.ServerId) return req } func outputResult(p *print.Printer, outputFormat, serverLabel string, volumeNames []string, volumes []iaas.VolumeAttachment) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(volumes, "", " ") - if err != nil { - return fmt.Errorf("marshal server volume list: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(volumes, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server volume list: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, volumes, func() error { table := tables.NewTable() table.SetHeader("SERVER ID", "SERVER NAME", "VOLUME ID", "VOLUME NAME") for i := range volumes { @@ -162,5 +139,5 @@ func outputResult(p *print.Printer, outputFormat, serverLabel string, volumeName return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/server/volume/list/list_test.go b/internal/cmd/server/volume/list/list_test.go index b9c37814f..c8c7339ab 100644 --- a/internal/cmd/server/volume/list/list_test.go +++ b/internal/cmd/server/volume/list/list_test.go @@ -4,17 +4,21 @@ import ( "context" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -25,8 +29,10 @@ var testServerId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - serverIdFlag: testServerId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + serverIdFlag: testServerId, } for _, mod := range mods { mod(flagValues) @@ -39,8 +45,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, } for _, mod := range mods { mod(model) @@ -49,7 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListAttachedVolumesRequest)) iaas.ApiListAttachedVolumesRequest { - request := testClient.ListAttachedVolumes(testCtx, testProjectId, testServerId) + request := testClient.ListAttachedVolumes(testCtx, testProjectId, testRegion, testServerId) for _, mod := range mods { mod(&request) } @@ -59,6 +66,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListAttachedVolumesRequest)) i func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -77,21 +85,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -120,46 +128,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -220,7 +189,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serverLabel, tt.args.volumeNames, tt.args.volumes); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/volume/update/update.go b/internal/cmd/server/volume/update/update.go index 5722808ef..389ad26a6 100644 --- a/internal/cmd/server/volume/update/update.go +++ b/internal/cmd/server/volume/update/update.go @@ -2,13 +2,15 @@ package update import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -30,12 +31,12 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - ServerId *string + ServerId string VolumeId string DeleteOnTermination *bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", volumeIdArg), Short: "Updates an attached volume of a server", @@ -49,39 +50,35 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId) if err != nil { - p.Debug(print.ErrorLevel, "get volume name: %v", err) - volumeLabel = model.VolumeId - } else if volumeLabel == "" { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) volumeLabel = model.VolumeId } - serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, *model.ServerId) + serverLabel, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId) if err != nil { - p.Debug(print.ErrorLevel, "get server name: %v", err) - serverLabel = *model.ServerId + params.Printer.Debug(print.ErrorLevel, "get server name: %v", err) + serverLabel = model.ServerId } else if serverLabel == "" { - serverLabel = *model.ServerId + serverLabel = model.ServerId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update attached volume %q of server %q?", volumeLabel, serverLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update attached volume %q of server %q?", volumeLabel, serverLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -91,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update server volume: %w", err) } - return outputResult(p, model.OutputFormat, volumeLabel, serverLabel, *resp) + return outputResult(params.Printer, model.OutputFormat, volumeLabel, serverLabel, *resp) }, } configureFlags(cmd) @@ -110,30 +107,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu volumeId := inputArgs[0] globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ GlobalFlagModel: globalFlags, - ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag), + ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag), DeleteOnTermination: flags.FlagToBoolPointer(p, cmd, deleteOnTerminationFlag), VolumeId: volumeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateAttachedVolumeRequest { - req := apiClient.UpdateAttachedVolume(ctx, model.ProjectId, *model.ServerId, model.VolumeId) + req := apiClient.UpdateAttachedVolume(ctx, model.ProjectId, model.Region, model.ServerId, model.VolumeId) payload := iaas.UpdateAttachedVolumePayload{ DeleteOnTermination: model.DeleteOnTermination, } @@ -141,25 +130,8 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat, volumeLabel, serverLabel string, volume iaas.VolumeAttachment) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(volume, "", " ") - if err != nil { - return fmt.Errorf("marshal server volume: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal server volume: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, volume, func() error { p.Outputf("Updated attached volume %q of server %q\n", volumeLabel, serverLabel) return nil - } + }) } diff --git a/internal/cmd/server/volume/update/update_test.go b/internal/cmd/server/volume/update/update_test.go index 46d5a0d20..532b37d5a 100644 --- a/internal/cmd/server/volume/update/update_test.go +++ b/internal/cmd/server/volume/update/update_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,7 +40,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + serverIdFlag: testServerId, deleteOnTerminationFlag: "true", } @@ -51,8 +57,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, - ServerId: utils.Ptr(testServerId), + ServerId: testServerId, VolumeId: testVolumeId, DeleteOnTermination: utils.Ptr(true), } @@ -73,7 +80,7 @@ func fixturePayload(mods ...func(payload *iaas.UpdateAttachedVolumePayload)) iaa } func fixtureRequest(mods ...func(request *iaas.ApiUpdateAttachedVolumeRequest)) iaas.ApiUpdateAttachedVolumeRequest { - request := testClient.UpdateAttachedVolume(testCtx, testProjectId, testServerId, testVolumeId) + request := testClient.UpdateAttachedVolume(testCtx, testProjectId, testRegion, testServerId, testVolumeId) request = request.UpdateAttachedVolumePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -105,7 +112,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -113,7 +120,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -121,7 +128,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -170,7 +177,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -268,7 +275,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.volumeLabel, tt.args.serverLabel, tt.args.volume); (err != nil) != tt.wantErr { diff --git a/internal/cmd/server/volume/volume.go b/internal/cmd/server/volume/volume.go index c851bbcd7..444ef040b 100644 --- a/internal/cmd/server/volume/volume.go +++ b/internal/cmd/server/volume/volume.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/server/volume/list" "github.com/stackitcloud/stackit-cli/internal/cmd/server/volume/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "volume", Short: "Provides functionality for server volumes", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(attach.NewCmd(p)) - cmd.AddCommand(detach.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(attach.NewCmd(params)) + cmd.AddCommand(detach.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) } diff --git a/internal/cmd/service-account/create/create.go b/internal/cmd/service-account/create/create.go index eedf109aa..8d9daf092 100644 --- a/internal/cmd/service-account/create/create.go +++ b/internal/cmd/service-account/create/create.go @@ -2,10 +2,10 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +29,7 @@ type inputModel struct { Name *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a service account", @@ -40,31 +40,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Create a service account with name "my-service-account"`, "$ stackit service-account create --name my-service-account"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a service account for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a service account for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -74,7 +72,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create service account: %w", err) } - return outputResult(p, model.OutputFormat, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, projectLabel, resp) }, } configureFlags(cmd) @@ -88,7 +86,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -99,15 +97,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Name: flags.FlagToStringPointer(p, cmd, nameFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -124,25 +114,8 @@ func outputResult(p *print.Printer, outputFormat, projectLabel string, serviceAc return fmt.Errorf("service account is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(serviceAccount, "", " ") - if err != nil { - return fmt.Errorf("marshal service account: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(serviceAccount, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal service account: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, serviceAccount, func() error { p.Outputf("Created service account for project %q. Email: %s\n", projectLabel, utils.PtrString(serviceAccount.Email)) return nil - } + }) } diff --git a/internal/cmd/service-account/create/create_test.go b/internal/cmd/service-account/create/create_test.go index d74e9ad3f..5418822eb 100644 --- a/internal/cmd/service-account/create/create_test.go +++ b/internal/cmd/service-account/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -61,6 +64,7 @@ func fixtureRequest(mods ...func(request *serviceaccount.ApiCreateServiceAccount func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -116,46 +120,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -213,7 +178,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.projectLabel, tt.args.serviceAccount); (err != nil) != tt.wantErr { diff --git a/internal/cmd/service-account/delete/delete.go b/internal/cmd/service-account/delete/delete.go index 3bb571f87..eefc8a3e3 100644 --- a/internal/cmd/service-account/delete/delete.go +++ b/internal/cmd/service-account/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -24,7 +26,7 @@ type inputModel struct { Email string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", emailArg), Short: "Deletes a service account", @@ -37,23 +39,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) 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 = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete service account %s? (This cannot be undone)", model.Email) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete service account: %w", err) } - p.Info("Service account %s deleted\n", model.Email) + params.Printer.Info("Service account %s deleted\n", model.Email) return nil }, } @@ -86,15 +86,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Email: email, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/service-account/delete/delete_test.go b/internal/cmd/service-account/delete/delete_test.go index a48cf2998..7dbcc6dfd 100644 --- a/internal/cmd/service-account/delete/delete_test.go +++ b/internal/cmd/service-account/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -125,54 +125,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/service-account/get-jwks/get_jwks.go b/internal/cmd/service-account/get-jwks/get_jwks.go index e86858c3e..340df926f 100644 --- a/internal/cmd/service-account/get-jwks/get_jwks.go +++ b/internal/cmd/service-account/get-jwks/get_jwks.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -22,7 +24,7 @@ type inputModel struct { Email string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("get-jwks %s", emailArg), Short: "Shows the JWKS for a service account", @@ -35,13 +37,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -54,11 +56,11 @@ func NewCmd(p *print.Printer) *cobra.Command { } jwks := *resp.Keys if len(jwks) == 0 { - p.Info("Empty JWKS for service account %s\n", model.Email) + params.Printer.Info("Empty JWKS for service account %s\n", model.Email) return nil } - return outputResult(p, jwks) + return outputResult(params.Printer, jwks) }, } @@ -72,15 +74,7 @@ func parseInput(p *print.Printer, _ *cobra.Command, inputArgs []string) (*inputM Email: email, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, 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 index 93b049d14..cd1ed4435 100644 --- a/internal/cmd/service-account/get-jwks/get_jwks_test.go +++ b/internal/cmd/service-account/get-jwks/get_jwks_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -69,7 +71,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -168,7 +170,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.serviceAccounts); (err != nil) != tt.wantErr { diff --git a/internal/cmd/service-account/key/create/create.go b/internal/cmd/service-account/key/create/create.go index 8cf058592..49bdd3918 100644 --- a/internal/cmd/service-account/key/create/create.go +++ b/internal/cmd/service-account/key/create/create.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -33,7 +35,7 @@ type inputModel struct { PublicKey *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a service account key", @@ -54,29 +56,27 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) 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 = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + 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 = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -86,13 +86,13 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create service account key: %w", err) } - p.Info("Created key for service account %s with ID %q\n", model.ServiceAccountEmail, *resp.Id) + params.Printer.Info("Created key for service account %s with ID %q\n", model.ServiceAccountEmail, *resp.Id) key, err := json.MarshalIndent(resp, "", " ") if err != nil { return fmt.Errorf("marshal key: %w", err) } - p.Outputln(string(key)) + params.Printer.Outputln(string(key)) return nil }, } @@ -110,7 +110,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -139,15 +139,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { PublicKey: flags.FlagToStringPointer(p, cmd, publicKeyFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/service-account/key/create/create_test.go b/internal/cmd/service-account/key/create/create_test.go index f2ded7df9..3ee9c7739 100644 --- a/internal/cmd/service-account/key/create/create_test.go +++ b/internal/cmd/service-account/key/create/create_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -64,6 +64,7 @@ func fixtureRequest(mods ...func(request *serviceaccount.ApiCreateServiceAccount func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -141,46 +142,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/service-account/key/delete/delete.go b/internal/cmd/service-account/key/delete/delete.go index 20e79fa74..c468d72a7 100644 --- a/internal/cmd/service-account/key/delete/delete.go +++ b/internal/cmd/service-account/key/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -30,7 +32,7 @@ type inputModel struct { KeyId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", keyIdArg), Short: "Deletes a service account key", @@ -43,23 +45,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) 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 = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete the key %s from service account %s?", model.KeyId, model.ServiceAccountEmail) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -69,7 +69,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("delete key: %w", err) } - p.Info("Deleted key %s from service account %s\n", model.KeyId, model.ServiceAccountEmail) + params.Printer.Info("Deleted key %s from service account %s\n", model.KeyId, model.ServiceAccountEmail) return nil }, } @@ -107,15 +107,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu KeyId: keyId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/service-account/key/delete/delete_test.go b/internal/cmd/service-account/key/delete/delete_test.go index ca808acd5..7f4ade070 100644 --- a/internal/cmd/service-account/key/delete/delete_test.go +++ b/internal/cmd/service-account/key/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -148,54 +148,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/service-account/key/describe/describe.go b/internal/cmd/service-account/key/describe/describe.go index 61bc9af84..84fb62dd8 100644 --- a/internal/cmd/service-account/key/describe/describe.go +++ b/internal/cmd/service-account/key/describe/describe.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -31,7 +33,7 @@ type inputModel struct { KeyId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", keyIdArg), Short: "Shows details of a service account key", @@ -44,12 +46,12 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -61,7 +63,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read service account key: %w", err) } - return outputResult(p, resp) + return outputResult(params.Printer, resp) }, } configureFlags(cmd) @@ -97,15 +99,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu KeyId: keyId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/service-account/key/describe/describe_test.go b/internal/cmd/service-account/key/describe/describe_test.go index 18cd60082..5884f4ce4 100644 --- a/internal/cmd/service-account/key/describe/describe_test.go +++ b/internal/cmd/service-account/key/describe/describe_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -148,54 +151,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -253,7 +209,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.key); (err != nil) != tt.wantErr { diff --git a/internal/cmd/service-account/key/key.go b/internal/cmd/service-account/key/key.go index 969e3df91..36d982f4e 100644 --- a/internal/cmd/service-account/key/key.go +++ b/internal/cmd/service-account/key/key.go @@ -7,13 +7,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/service-account/key/list" "github.com/stackitcloud/stackit-cli/internal/cmd/service-account/key/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "key", Short: "Provides functionality for service account keys", @@ -21,14 +21,14 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) } diff --git a/internal/cmd/service-account/key/list/list.go b/internal/cmd/service-account/key/list/list.go index 66c7fafcb..2cb1d3288 100644 --- a/internal/cmd/service-account/key/list/list.go +++ b/internal/cmd/service-account/key/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-account/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" ) const ( @@ -31,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all service account keys", @@ -48,15 +49,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -69,7 +70,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } keys := *resp.Items if len(keys) == 0 { - p.Info("No keys found for service account %s\n", model.ServiceAccountEmail) + params.Printer.Info("No keys found for service account %s\n", model.ServiceAccountEmail) return nil } @@ -78,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command { keys = keys[:*model.Limit] } - return outputResult(p, model.OutputFormat, keys) + return outputResult(params.Printer, model.OutputFormat, keys) }, } @@ -94,7 +95,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -122,15 +123,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -140,24 +133,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceacco } func outputResult(p *print.Printer, outputFormat string, keys []serviceaccount.ServiceAccountKeyListResponse) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(keys, "", " ") - if err != nil { - return fmt.Errorf("marshal keys metadata: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(keys, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal keys metadata: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, keys, func() error { table := tables.NewTable() table.SetHeader("ID", "ACTIVE", "CREATED_AT", "VALID_UNTIL") for i := range keys { @@ -179,5 +155,5 @@ func outputResult(p *print.Printer, outputFormat string, keys []serviceaccount.S } return nil - } + }) } diff --git a/internal/cmd/service-account/key/list/list_test.go b/internal/cmd/service-account/key/list/list_test.go index 46933a70c..043b1b522 100644 --- a/internal/cmd/service-account/key/list/list_test.go +++ b/internal/cmd/service-account/key/list/list_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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" ) @@ -62,6 +64,7 @@ func fixtureRequest(mods ...func(request *serviceaccount.ApiListServiceAccountKe func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -123,48 +126,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -228,7 +190,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.keys); (err != nil) != tt.wantErr { diff --git a/internal/cmd/service-account/key/update/update.go b/internal/cmd/service-account/key/update/update.go index 5aba23b81..5594396e4 100644 --- a/internal/cmd/service-account/key/update/update.go +++ b/internal/cmd/service-account/key/update/update.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -38,7 +40,7 @@ type inputModel struct { Deactivate bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", keyIdArg), Short: "Updates a service account key", @@ -60,23 +62,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update the key with ID %q?", model.KeyId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update the key with ID %q?", model.KeyId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -90,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("marshal key: %w", err) } - p.Info("%s", string(key)) + params.Printer.Info("%s", string(key)) return nil }, } @@ -148,15 +148,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Deactivate: deactivate, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/service-account/key/update/update_test.go b/internal/cmd/service-account/key/update/update_test.go index 706739579..2d2c66bfa 100644 --- a/internal/cmd/service-account/key/update/update_test.go +++ b/internal/cmd/service-account/key/update/update_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -195,54 +195,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/service-account/list/list.go b/internal/cmd/service-account/list/list.go index c4ea786d2..692ea90c8 100644 --- a/internal/cmd/service-account/list/list.go +++ b/internal/cmd/service-account/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-account/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" ) const ( @@ -29,7 +30,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all service accounts", @@ -40,15 +41,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `List all service accounts`, "$ stackit service-account list"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -61,12 +62,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } serviceAccounts := *resp.Items if len(serviceAccounts) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No service accounts found for project %q\n", projectLabel) + params.Printer.Info("No service accounts found for project %q\n", projectLabel) return nil } @@ -75,7 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command { serviceAccounts = serviceAccounts[:*model.Limit] } - return outputResult(p, model.OutputFormat, serviceAccounts) + return outputResult(params.Printer, model.OutputFormat, serviceAccounts) }, } @@ -87,7 +88,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -106,15 +107,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -124,20 +117,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceacco } func outputResult(p *print.Printer, outputFormat string, serviceAccounts []serviceaccount.ServiceAccount) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(serviceAccounts, "", " ") - if err != nil { - return fmt.Errorf("marshal service accounts list: %w", err) - } - p.Outputln(string(details)) - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(serviceAccounts, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal service accounts list: %w", err) - } - p.Outputln(string(details)) - default: + return p.OutputResult(outputFormat, serviceAccounts, func() error { table := tables.NewTable() table.SetHeader("ID", "EMAIL") for i := range serviceAccounts { @@ -151,7 +131,6 @@ func outputResult(p *print.Printer, outputFormat string, serviceAccounts []servi if err != nil { return fmt.Errorf("render table: %w", err) } - } - - return nil + return nil + }) } diff --git a/internal/cmd/service-account/list/list_test.go b/internal/cmd/service-account/list/list_test.go index d212eabc8..051d78044 100644 --- a/internal/cmd/service-account/list/list_test.go +++ b/internal/cmd/service-account/list/list_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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" ) @@ -59,6 +61,7 @@ func fixtureRequest(mods ...func(request *serviceaccount.ApiListServiceAccountsR func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -113,48 +116,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -218,7 +180,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serviceAccounts); (err != nil) != tt.wantErr { diff --git a/internal/cmd/service-account/service_account.go b/internal/cmd/service-account/service_account.go index 8a4f4634d..856be2fba 100644 --- a/internal/cmd/service-account/service_account.go +++ b/internal/cmd/service-account/service_account.go @@ -8,13 +8,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/service-account/list" "github.com/stackitcloud/stackit-cli/internal/cmd/service-account/token" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "service-account", Short: "Provides functionality for service accounts", @@ -22,16 +22,16 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(getjwks.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(getjwks.NewCmd(params)) - cmd.AddCommand(key.NewCmd(p)) - cmd.AddCommand(token.NewCmd(p)) + cmd.AddCommand(key.NewCmd(params)) + cmd.AddCommand(token.NewCmd(params)) } diff --git a/internal/cmd/service-account/token/create/create.go b/internal/cmd/service-account/token/create/create.go index 8f96b4002..c5e8f9a18 100644 --- a/internal/cmd/service-account/token/create/create.go +++ b/internal/cmd/service-account/token/create/create.go @@ -2,11 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-account/client" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" ) const ( @@ -32,7 +33,7 @@ type inputModel struct { TTLDays *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates an access token for a service account", @@ -50,25 +51,23 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) 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 = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create an access token for service account %s?", model.ServiceAccountEmail) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -78,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("create access token: %w", err) } - return outputResult(p, model.OutputFormat, model.ServiceAccountEmail, token) + return outputResult(params.Printer, model.OutputFormat, model.ServiceAccountEmail, token) }, } @@ -94,7 +93,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -122,15 +121,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { TTLDays: &ttlDays, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -147,27 +138,10 @@ func outputResult(p *print.Printer, outputFormat, serviceAccountEmail string, to return fmt.Errorf("token is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(token, "", " ") - if err != nil { - return fmt.Errorf("marshal service account access token: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(token, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal service account access token: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, token, func() error { p.Outputf("Created access token for service account %s. Token ID: %s\n\n", serviceAccountEmail, utils.PtrString(token.Id)) p.Outputf("Valid until: %s\n", utils.PtrString(token.ValidUntil)) p.Outputf("Token: %s\n", utils.PtrString(token.Token)) return nil - } + }) } diff --git a/internal/cmd/service-account/token/create/create_test.go b/internal/cmd/service-account/token/create/create_test.go index d5d18e3e9..3dfc4340b 100644 --- a/internal/cmd/service-account/token/create/create_test.go +++ b/internal/cmd/service-account/token/create/create_test.go @@ -4,8 +4,11 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/google/go-cmp/cmp" @@ -66,6 +69,7 @@ func fixtureRequest(mods ...func(request *serviceaccount.ApiCreateAccessTokenReq func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -120,46 +124,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -219,7 +184,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.serviceAccountEmail, tt.args.token); (err != nil) != tt.wantErr { diff --git a/internal/cmd/service-account/token/list/list.go b/internal/cmd/service-account/token/list/list.go index 48b793f42..436d599f6 100644 --- a/internal/cmd/service-account/token/list/list.go +++ b/internal/cmd/service-account/token/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -32,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists access tokens of a service account", @@ -53,15 +53,15 @@ func NewCmd(p *print.Printer) *cobra.Command { `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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -74,7 +74,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } tokensMetadata := *resp.Items if len(tokensMetadata) == 0 { - p.Info("No tokens found for service account with email %q\n", model.ServiceAccountEmail) + params.Printer.Info("No tokens found for service account with email %q\n", model.ServiceAccountEmail) return nil } @@ -83,7 +83,7 @@ func NewCmd(p *print.Printer) *cobra.Command { tokensMetadata = tokensMetadata[:*model.Limit] } - return outputResult(p, model.OutputFormat, tokensMetadata) + return outputResult(params.Printer, model.OutputFormat, tokensMetadata) }, } @@ -99,7 +99,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -127,15 +127,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: limit, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -145,24 +137,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceacco } func outputResult(p *print.Printer, outputFormat string, tokensMetadata []serviceaccount.AccessTokenMetadata) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(tokensMetadata, "", " ") - if err != nil { - return fmt.Errorf("marshal tokens metadata: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(tokensMetadata, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal tokens metadata: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, tokensMetadata, func() error { table := tables.NewTable() table.SetHeader("ID", "ACTIVE", "CREATED_AT", "VALID_UNTIL") for i := range tokensMetadata { @@ -180,5 +155,5 @@ func outputResult(p *print.Printer, outputFormat string, tokensMetadata []servic } return nil - } + }) } diff --git a/internal/cmd/service-account/token/list/list_test.go b/internal/cmd/service-account/token/list/list_test.go index fc3f5b54b..1410b275a 100644 --- a/internal/cmd/service-account/token/list/list_test.go +++ b/internal/cmd/service-account/token/list/list_test.go @@ -4,14 +4,16 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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" ) @@ -62,6 +64,7 @@ func fixtureRequest(mods ...func(request *serviceaccount.ApiListAccessTokensRequ func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -123,48 +126,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -228,7 +190,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.tokensMetadata); (err != nil) != tt.wantErr { diff --git a/internal/cmd/service-account/token/revoke/revoke.go b/internal/cmd/service-account/token/revoke/revoke.go index 212f5c2a6..2a892e005 100644 --- a/internal/cmd/service-account/token/revoke/revoke.go +++ b/internal/cmd/service-account/token/revoke/revoke.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -29,7 +31,7 @@ type inputModel struct { TokenId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("revoke %s", tokenIdArg), Short: "Revokes an access token of a service account", @@ -46,23 +48,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to revoke the access token with ID %q?", model.TokenId) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to revoke the access token with ID %q?", model.TokenId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -72,7 +72,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("revoke access token: %w", err) } - p.Info("Revoked access token with ID %q\n", model.TokenId) + params.Printer.Info("Revoked access token with ID %q\n", model.TokenId) return nil }, } @@ -110,15 +110,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu TokenId: tokenId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/service-account/token/revoke/revoke_test.go b/internal/cmd/service-account/token/revoke/revoke_test.go index 3cf5b1246..cebb61897 100644 --- a/internal/cmd/service-account/token/revoke/revoke_test.go +++ b/internal/cmd/service-account/token/revoke/revoke_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -148,54 +148,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/service-account/token/token.go b/internal/cmd/service-account/token/token.go index 45570ea97..5faeff394 100644 --- a/internal/cmd/service-account/token/token.go +++ b/internal/cmd/service-account/token/token.go @@ -5,13 +5,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/service-account/token/list" "github.com/stackitcloud/stackit-cli/internal/cmd/service-account/token/revoke" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "token", Short: "Provides functionality for service account tokens", @@ -19,12 +19,12 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(revoke.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(revoke.NewCmd(params)) } diff --git a/internal/cmd/ske/cluster/cluster.go b/internal/cmd/ske/cluster/cluster.go index c5bb7b36b..e4dd6bcbb 100644 --- a/internal/cmd/ske/cluster/cluster.go +++ b/internal/cmd/ske/cluster/cluster.go @@ -5,16 +5,20 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/describe" generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/generate-payload" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/hibernate" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/maintenance" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/reconcile" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/update" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/cluster/wakeup" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "cluster", Short: "Provides functionality for SKE cluster", @@ -22,15 +26,19 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(generatepayload.NewCmd(p)) - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(generatepayload.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(hibernate.NewCmd(params)) + cmd.AddCommand(maintenance.NewCmd(params)) + cmd.AddCommand(reconcile.NewCmd(params)) + cmd.AddCommand(wakeup.NewCmd(params)) } diff --git a/internal/cmd/ske/cluster/create/create.go b/internal/cmd/ske/cluster/create/create.go index 195b343ca..eafcdc349 100644 --- a/internal/cmd/ske/cluster/create/create.go +++ b/internal/cmd/ske/cluster/create/create.go @@ -5,8 +5,12 @@ import ( "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -20,8 +24,6 @@ import ( skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/ske" - "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" ) const ( @@ -36,10 +38,10 @@ type inputModel struct { Payload *ske.CreateOrUpdateClusterPayload } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("create %s", clusterNameArg), - Short: "Creates an SKE cluster", + Short: "Creates a 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 \"@\".", @@ -48,13 +50,13 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( examples.NewExample( - `Create an SKE cluster using default configuration`, + `Create a 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"`, + `Create a 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`, + `Create a 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`, @@ -64,33 +66,31 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a cluster for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a cluster for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Configure ServiceEnable API client - serviceEnablementApiClient, err := serviceEnablementClient.ConfigureClient(p) + serviceEnablementApiClient, err := serviceEnablementClient.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -107,7 +107,7 @@ func NewCmd(p *print.Printer) *cobra.Command { } // Check if cluster exists - exists, err := skeUtils.ClusterExists(ctx, apiClient, model.ProjectId, model.ClusterName) + exists, err := skeUtils.ClusterExists(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName) if err != nil { return err } @@ -117,7 +117,7 @@ func NewCmd(p *print.Printer) *cobra.Command { // Fill in default payload, if needed if model.Payload == nil { - defaultPayload, err := skeUtils.GetDefaultPayload(ctx, apiClient) + defaultPayload, err := skeUtils.GetDefaultPayload(ctx, apiClient.DefaultAPI, model.Region) if err != nil { return fmt.Errorf("get default payload: %w", err) } @@ -134,16 +134,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating cluster") - _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient, model.ProjectId, name).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating cluster", func() error { + _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, name).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for SKE cluster creation: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, projectLabel, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp) }, } configureFlags(cmd) @@ -178,20 +178,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Payload: payload, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiCreateOrUpdateClusterRequest { - req := apiClient.CreateOrUpdateCluster(ctx, model.ProjectId, model.ClusterName) + req := apiClient.DefaultAPI.CreateOrUpdateCluster(ctx, model.ProjectId, model.Region, model.ClusterName) req = req.CreateOrUpdateClusterPayload(*model.Payload) return req @@ -202,29 +194,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, projectLabe return fmt.Errorf("cluster is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(cluster, "", " ") - if err != nil { - return fmt.Errorf("marshal SKE cluster: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(cluster, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SKE cluster: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, cluster, func() error { operationState := "Created" if async { operationState = "Triggered creation of" } p.Outputf("%s cluster for project %q. Cluster name: %s\n", operationState, projectLabel, utils.PtrString(cluster.Name)) return nil - } + }) } diff --git a/internal/cmd/ske/cluster/create/create_test.go b/internal/cmd/ske/cluster/create/create_test.go index 6e2e92b3f..896e9421a 100644 --- a/internal/cmd/ske/cluster/create/create_test.go +++ b/internal/cmd/ske/cluster/create/create_test.go @@ -5,6 +5,10 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -12,7 +16,7 @@ import ( "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" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -20,51 +24,67 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() var testClusterName = "cluster" +const testRegion = "eu01" + var testPayload = &ske.CreateOrUpdateClusterPayload{ - Kubernetes: &ske.Kubernetes{ - Version: utils.Ptr("1.25.15"), + Kubernetes: ske.Kubernetes{ + Version: "1.25.15", + AdditionalProperties: map[string]any{}, }, - Nodepools: &[]ske.Nodepool{ + Nodepools: []ske.Nodepool{ { - Name: utils.Ptr("np-name"), - Machine: &ske.Machine{ - Image: &ske.Image{ - Name: utils.Ptr("flatcar"), - Version: utils.Ptr("3760.2.1"), + Name: "np-name", + Machine: ske.Machine{ + Image: ske.Image{ + Name: "flatcar", + Version: "3760.2.1", + AdditionalProperties: map[string]any{}, }, - Type: utils.Ptr("b1.2"), + Type: "b1.2", + AdditionalProperties: map[string]any{}, + }, + Minimum: int32(1), + Maximum: int32(2), + MaxSurge: utils.Ptr(int32(1)), + Volume: ske.Volume{ + Type: utils.Ptr("storage_premium_perf0"), + Size: int32(40), + AdditionalProperties: map[string]any{}, }, - 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("containerd"), + AdditionalProperties: map[string]any{}, }, - AvailabilityZones: &[]string{"eu01-3"}, - Cri: &ske.CRI{Name: utils.Ptr("cri")}, + AdditionalProperties: map[string]any{}, }, }, Extensions: &ske.Extension{ Acl: &ske.ACL{ - Enabled: utils.Ptr(true), - AllowedCidrs: &[]string{"0.0.0.0/0"}, + Enabled: true, + AllowedCidrs: []string{"0.0.0.0/0"}, + AdditionalProperties: map[string]any{}, }, + AdditionalProperties: map[string]any{}, }, Maintenance: &ske.Maintenance{ - AutoUpdate: &ske.MaintenanceAutoUpdate{ - KubernetesVersion: utils.Ptr(true), - MachineImageVersion: utils.Ptr(true), + AutoUpdate: ske.MaintenanceAutoUpdate{ + KubernetesVersion: utils.Ptr(true), + MachineImageVersion: utils.Ptr(true), + AdditionalProperties: map[string]any{}, }, - TimeWindow: &ske.TimeWindow{ - End: utils.Ptr(time.Date(0, 1, 1, 5, 0, 0, 0, time.FixedZone("test-zone", 2*60*60))), - Start: utils.Ptr(time.Date(0, 1, 1, 3, 0, 0, 0, time.FixedZone("test-zone", 2*60*60))), + TimeWindow: ske.TimeWindow{ + End: time.Date(0, 1, 1, 5, 0, 0, 0, time.FixedZone("test-zone", 2*60*60)), + Start: time.Date(0, 1, 1, 3, 0, 0, 0, time.FixedZone("test-zone", 2*60*60)), + AdditionalProperties: map[string]any{}, }, + AdditionalProperties: map[string]any{}, }, + AdditionalProperties: map[string]any{}, } func fixtureArgValues(mods ...func(argValues []string)) []string { @@ -79,9 +99,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, payloadFlag: `{ - "name": "cli-jp", "kubernetes": { "version": "1.25.15" }, @@ -99,7 +119,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st "maximum": 2, "maxSurge": 1, "volume": { "type": "storage_premium_perf0", "size": 40 }, - "cri": { "name": "cri" }, + "cri": { "name": "containerd" }, "availabilityZones": ["eu01-3"] } ], @@ -126,6 +146,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ClusterName: testClusterName, @@ -138,7 +159,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiCreateOrUpdateClusterRequest)) ske.ApiCreateOrUpdateClusterRequest { - request := testClient.CreateOrUpdateCluster(testCtx, testProjectId, fixtureInputModel().ClusterName) + request := testClient.DefaultAPI.CreateOrUpdateCluster(testCtx, testProjectId, testRegion, fixtureInputModel().ClusterName) request = request.CreateOrUpdateClusterPayload(*testPayload) for _, mod := range mods { mod(&request) @@ -227,62 +248,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -308,6 +274,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -342,7 +309,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.projectLabel, tt.args.cluster); (err != nil) != tt.wantErr { diff --git a/internal/cmd/ske/cluster/delete/delete.go b/internal/cmd/ske/cluster/delete/delete.go index 657001abe..f8833c202 100644 --- a/internal/cmd/ske/cluster/delete/delete.go +++ b/internal/cmd/ske/cluster/delete/delete.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +15,8 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/ske" - "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" ) const ( @@ -26,7 +28,7 @@ type inputModel struct { ClusterName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", clusterNameArg), Short: "Deletes a SKE cluster", @@ -34,28 +36,26 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( examples.NewExample( - `Delete an SKE cluster with name "my-cluster"`, + `Delete a 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(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete cluster %q? (This cannot be undone)", model.ClusterName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete cluster %q? (This cannot be undone)", model.ClusterName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -67,20 +67,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting cluster") - _, err = wait.DeleteClusterWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting cluster", func() error { + _, err = wait.DeleteClusterWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for SKE cluster deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s cluster %q\n", operationState, model.ClusterName) + params.Printer.Info("%s cluster %q\n", operationState, model.ClusterName) return nil }, } @@ -100,19 +100,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ClusterName: clusterName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiDeleteClusterRequest { - req := apiClient.DeleteCluster(ctx, model.ProjectId, model.ClusterName) + req := apiClient.DefaultAPI.DeleteCluster(ctx, model.ProjectId, model.Region, model.ClusterName) return req } diff --git a/internal/cmd/ske/cluster/delete/delete_test.go b/internal/cmd/ske/cluster/delete/delete_test.go index b15c7254c..182af1949 100644 --- a/internal/cmd/ske/cluster/delete/delete_test.go +++ b/internal/cmd/ske/cluster/delete/delete_test.go @@ -5,12 +5,12 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -18,10 +18,12 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() var testClusterName = "cluster" +const testRegion = "eu01" + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testClusterName, @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ClusterName: testClusterName, @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiDeleteClusterRequest)) ske.ApiDeleteClusterRequest { - request := testClient.DeleteCluster(testCtx, testProjectId, testClusterName) + request := testClient.DefaultAPI.DeleteCluster(testCtx, testProjectId, testRegion, testClusterName) for _, mod := range mods { mod(&request) } @@ -125,54 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -199,6 +156,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) 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 index 52fb7af4d..bde8763f7 100644 --- a/internal/cmd/ske/cluster/describe/describe.go +++ b/internal/cmd/ske/cluster/describe/describe.go @@ -2,11 +2,14 @@ package describe import ( "context" - "encoding/json" "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" - "github.com/goccy/go-yaml" "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -15,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/ske" ) const ( @@ -27,28 +29,28 @@ type inputModel struct { ClusterName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", clusterNameArg), - Short: "Shows details of a SKE cluster", - Long: "Shows details of a STACKIT Kubernetes Engine (SKE) cluster.", + Short: "Shows details of a SKE cluster", + Long: "Shows 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"`, + `Get details of a 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 JSON format`, + `Get details of a SKE cluster with name "my-cluster" in JSON format`, "$ stackit ske cluster describe my-cluster --output-format json"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -60,7 +62,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read SKE cluster: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -79,20 +81,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ClusterName: clusterName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiGetClusterRequest { - req := apiClient.GetCluster(ctx, model.ProjectId, model.ClusterName) + req := apiClient.DefaultAPI.GetCluster(ctx, model.ProjectId, model.Region, model.ClusterName) return req } @@ -101,27 +95,10 @@ func outputResult(p *print.Printer, outputFormat string, cluster *ske.Cluster) e return fmt.Errorf("cluster is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(cluster, "", " ") - if err != nil { - return fmt.Errorf("marshal SKE cluster: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(cluster, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SKE cluster: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, cluster, func() error { acl := []string{} if cluster.Extensions != nil && cluster.Extensions.Acl != nil { - acl = *cluster.Extensions.Acl.AllowedCidrs + acl = cluster.Extensions.Acl.AllowedCidrs } table := tables.NewTable() @@ -130,11 +107,15 @@ func outputResult(p *print.Printer, outputFormat string, cluster *ske.Cluster) e if cluster.HasStatus() { table.AddRow("STATE", utils.PtrString(cluster.Status.Aggregated)) table.AddSeparator() + if clusterErrs := cluster.Status.GetErrors(); len(clusterErrs) != 0 { + handleClusterErrors(clusterErrs, &table) + } } - if cluster.Kubernetes != nil { - table.AddRow("VERSION", utils.PtrString(cluster.Kubernetes.Version)) + if cluster.Kubernetes.Version != "" { + table.AddRow("VERSION", cluster.Kubernetes.Version) table.AddSeparator() } + table.AddRow("ACL", acl) err := table.Display(p) if err != nil { @@ -142,5 +123,19 @@ func outputResult(p *print.Printer, outputFormat string, cluster *ske.Cluster) e } return nil + }) +} + +func handleClusterErrors(clusterErrs []ske.ClusterError, table *tables.Table) { + errs := make([]string, 0, len(clusterErrs)) + for _, e := range clusterErrs { + b := new(strings.Builder) + fmt.Fprint(b, e.GetCode()) + if msg, ok := e.GetMessageOk(); ok { + fmt.Fprintf(b, ": %s", *msg) + } + errs = append(errs, b.String()) } + table.AddRow("ERRORS", strings.Join(errs, "\n")) + table.AddSeparator() } diff --git a/internal/cmd/ske/cluster/describe/describe_test.go b/internal/cmd/ske/cluster/describe/describe_test.go index 99e644af4..026a1405e 100644 --- a/internal/cmd/ske/cluster/describe/describe_test.go +++ b/internal/cmd/ske/cluster/describe/describe_test.go @@ -4,13 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -18,10 +22,12 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() var testClusterName = "cluster" +const testRegion = "eu01" + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testClusterName, @@ -34,7 +40,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ClusterName: testClusterName, @@ -57,7 +65,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiGetClusterRequest)) ske.ApiGetClusterRequest { - request := testClient.GetCluster(testCtx, testProjectId, testClusterName) + request := testClient.DefaultAPI.GetCluster(testCtx, testProjectId, testRegion, testClusterName) for _, mod := range mods { mod(&request) } @@ -125,54 +133,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -199,6 +160,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -229,9 +191,187 @@ func TestOutputResult(t *testing.T) { }, wantErr: false, }, + { + name: "cluster with single error", + args: args{ + outputFormat: "", + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Status: &ske.ClusterStatus{ + Errors: []ske.ClusterError{ + { + Code: utils.Ptr("SKE_INFRA_SNA_NETWORK_NOT_FOUND"), + Message: utils.Ptr("Network configuration not found"), + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "cluster with multiple errors", + args: args{ + outputFormat: "", + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Status: &ske.ClusterStatus{ + Errors: []ske.ClusterError{ + { + Code: utils.Ptr("SKE_INFRA_SNA_NETWORK_NOT_FOUND"), + Message: utils.Ptr("Network configuration not found"), + }, + { + Code: utils.Ptr("SKE_NODE_MACHINE_TYPE_NOT_FOUND"), + Message: utils.Ptr("Specified machine type unavailable"), + }, + { + Code: utils.Ptr("SKE_FETCHING_ERRORS_NOT_POSSIBLE"), + Message: utils.Ptr("Fetching errors not possible"), + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "cluster with error but no message", + args: args{ + outputFormat: "", + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Status: &ske.ClusterStatus{ + Errors: []ske.ClusterError{ + { + Code: utils.Ptr("SKE_FETCHING_ERRORS_NOT_POSSIBLE"), + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "cluster with nil errors", + args: args{ + outputFormat: "", + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Status: &ske.ClusterStatus{ + Errors: nil, + }, + }, + }, + wantErr: false, + }, + { + name: "cluster with empty errors array", + args: args{ + outputFormat: "", + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Status: &ske.ClusterStatus{ + Errors: []ske.ClusterError{}, + }, + }, + }, + wantErr: false, + }, + { + name: "cluster without status", + args: args{ + outputFormat: "", + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + }, + }, + wantErr: false, + }, + { + name: "JSON output format with errors", + args: args{ + outputFormat: print.JSONOutputFormat, + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Status: &ske.ClusterStatus{ + Errors: []ske.ClusterError{ + { + Code: utils.Ptr("SKE_INFRA_SNA_NETWORK_NOT_FOUND"), + Message: utils.Ptr("Network configuration not found"), + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "YAML output format with errors", + args: args{ + outputFormat: print.YAMLOutputFormat, + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Status: &ske.ClusterStatus{ + Errors: []ske.ClusterError{ + { + Code: utils.Ptr("SKE_INFRA_SNA_NETWORK_NOT_FOUND"), + Message: utils.Ptr("Network configuration not found"), + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "cluster with kubernetes info and errors", + args: args{ + outputFormat: "", + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Kubernetes: ske.Kubernetes{ + Version: "1.28.0", + }, + Status: &ske.ClusterStatus{ + Errors: []ske.ClusterError{ + { + Code: utils.Ptr("SKE_INFRA_SNA_NETWORK_NOT_FOUND"), + Message: utils.Ptr("Network configuration not found"), + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "cluster with extensions and errors", + args: args{ + outputFormat: "", + cluster: &ske.Cluster{ + Name: utils.Ptr("test-cluster"), + Extensions: &ske.Extension{ + Acl: &ske.ACL{ + AllowedCidrs: []string{"10.0.0.0/8"}, + Enabled: true, + }, + }, + Status: &ske.ClusterStatus{ + Errors: []ske.ClusterError{ + { + Code: utils.Ptr("SKE_INFRA_SNA_NETWORK_NOT_FOUND"), + Message: utils.Ptr("Network configuration not found"), + }, + }, + }, + }, + }, + wantErr: false, + }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.cluster); (err != nil) != tt.wantErr { diff --git a/internal/cmd/ske/cluster/generate-payload/generate_payload.go b/internal/cmd/ske/cluster/generate-payload/generate_payload.go index 299b5e9ab..7ff0a87d3 100644 --- a/internal/cmd/ske/cluster/generate-payload/generate_payload.go +++ b/internal/cmd/ske/cluster/generate-payload/generate_payload.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,7 @@ import ( skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/utils" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/ske" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) const ( @@ -30,7 +32,7 @@ type inputModel struct { FilePath *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "generate-payload", Short: "Generates a payload to create/update SKE clusters", @@ -54,22 +56,22 @@ func NewCmd(p *print.Printer) *cobra.Command { `Generate a payload with values of a cluster, and preview it in the terminal`, `$ stackit ske cluster generate-payload --cluster-name my-cluster`), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } var payload *ske.CreateOrUpdateClusterPayload if model.ClusterName == nil { - payload, err = skeUtils.GetDefaultPayload(ctx, apiClient) + payload, err = skeUtils.GetDefaultPayload(ctx, apiClient.DefaultAPI, model.Region) if err != nil { return err } @@ -80,16 +82,18 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read SKE cluster: %w", err) } payload = &ske.CreateOrUpdateClusterPayload{ + Access: resp.Access, Extensions: resp.Extensions, Hibernation: resp.Hibernation, Kubernetes: resp.Kubernetes, Maintenance: resp.Maintenance, + Network: resp.Network, Nodepools: resp.Nodepools, Status: resp.Status, } } - return outputResult(p, model.FilePath, payload) + return outputResult(params.Printer, model.FilePath, payload) }, } configureFlags(cmd) @@ -101,7 +105,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the payload to the given file. If unset, writes the payload to the standard output") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) clusterName := flags.FlagToStringPointer(p, cmd, clusterNameFlag) @@ -116,20 +120,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { FilePath: flags.FlagToStringPointer(p, cmd, filePathFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiGetClusterRequest { - req := apiClient.GetCluster(ctx, model.ProjectId, *model.ClusterName) + req := apiClient.DefaultAPI.GetCluster(ctx, model.ProjectId, model.Region, *model.ClusterName) return req } diff --git a/internal/cmd/ske/cluster/generate-payload/generate_payload_test.go b/internal/cmd/ske/cluster/generate-payload/generate_payload_test.go index 5516ccf26..16c1ad392 100644 --- a/internal/cmd/ske/cluster/generate-payload/generate_payload_test.go +++ b/internal/cmd/ske/cluster/generate-payload/generate_payload_test.go @@ -4,14 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -19,7 +22,7 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() const ( @@ -27,11 +30,14 @@ const ( testFilePath = "example-file" ) +const testRegion = "eu01" + func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - clusterNameFlag: testClusterName, - filePathFlag: testFilePath, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + clusterNameFlag: testClusterName, + filePathFlag: testFilePath, } for _, mod := range mods { mod(flagValues) @@ -43,6 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ClusterName: utils.Ptr(testClusterName), @@ -55,7 +62,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiGetClusterRequest)) ske.ApiGetClusterRequest { - request := testClient.GetCluster(testCtx, testProjectId, testClusterName) + request := testClient.DefaultAPI.GetCluster(testCtx, testProjectId, testRegion, testClusterName) for _, mod := range mods { mod(&request) } @@ -65,6 +72,7 @@ func fixtureRequest(mods ...func(request *ske.ApiGetClusterRequest)) ske.ApiGetC func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -128,54 +136,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -201,6 +162,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -241,7 +203,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr { diff --git a/internal/cmd/ske/cluster/hibernate/hibernate.go b/internal/cmd/ske/cluster/hibernate/hibernate.go new file mode 100644 index 000000000..b2a345175 --- /dev/null +++ b/internal/cmd/ske/cluster/hibernate/hibernate.go @@ -0,0 +1,116 @@ +package hibernate + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("hibernate %s", clusterNameArg), + Short: "Trigger hibernate for a SKE cluster", + Long: "Trigger hibernate for a STACKIT Kubernetes Engine (SKE) cluster.", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Trigger hibernate for a SKE cluster with name "my-cluster"`, + "$ stackit ske cluster hibernate my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to trigger hibernate for %q in project %q?", model.ClusterName, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("hibernate SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Hibernating cluster", func() error { + _, err = wait.TriggerClusterHibernationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for SKE cluster hibernation: %w", err) + } + } + + operationState := "Hibernated" + if model.Async { + operationState = "Triggered hibernation of" + } + params.Printer.Outputf("%s cluster %q\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerHibernateRequest { + req := apiClient.DefaultAPI.TriggerHibernate(ctx, model.ProjectId, model.Region, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/hibernate/hibernate_test.go b/internal/cmd/ske/cluster/hibernate/hibernate_test.go new file mode 100644 index 000000000..0e532170f --- /dev/null +++ b/internal/cmd/ske/cluster/hibernate/hibernate_test.go @@ -0,0 +1,188 @@ +package hibernate + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testClusterName = "my-cluster" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +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{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiTriggerHibernateRequest)) ske.ApiTriggerHibernateRequest { + request := testClient.DefaultAPI.TriggerHibernate(testCtx, testProjectId, testRegion, 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: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + delete(fv, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id - empty string", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid uuid format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + 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) + } + } + + if len(tt.argValues) == 0 { + _, err := parseInput(p, cmd, tt.argValues) + if err == nil && !tt.isValid { + t.Fatalf("expected error due to missing args") + } + return + } + + model, err := parseInput(p, 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:\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerHibernateRequest + }{ + { + 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, + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("request mismatch:\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/list/list.go b/internal/cmd/ske/cluster/list/list.go index 7b5389454..054c4f508 100644 --- a/internal/cmd/ske/cluster/list/list.go +++ b/internal/cmd/ske/cluster/list/list.go @@ -2,10 +2,10 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -20,7 +20,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/ske" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) const ( @@ -32,7 +32,7 @@ type inputModel struct { Limit *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all SKE clusters", @@ -49,21 +49,21 @@ func NewCmd(p *print.Printer) *cobra.Command { `List up to 10 SKE clusters`, "$ stackit ske cluster list --limit 10"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } // Configure ServiceEnable API client - serviceEnablementApiClient, err := serviceEnablementClient.ConfigureClient(p) + serviceEnablementApiClient, err := serviceEnablementClient.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -83,23 +83,22 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("get SKE clusters: %w", err) } - clusters := *resp.Items - if len(clusters) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) - if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) - projectLabel = model.ProjectId - } - p.Info("No clusters found for project %q\n", projectLabel) - return nil - } + clusters := resp.Items // Truncate output if model.Limit != nil && len(clusters) > int(*model.Limit) { clusters = clusters[:*model.Limit] } - return outputResult(p, model.OutputFormat, clusters) + projectLabel := model.ProjectId + if len(clusters) == 0 { + projectLabel, err = projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, projectLabel, clusters) }, } @@ -111,7 +110,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -130,60 +129,38 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiListClustersRequest { - req := apiClient.ListClusters(ctx, model.ProjectId) + req := apiClient.DefaultAPI.ListClusters(ctx, model.ProjectId, model.Region) return req } -func outputResult(p *print.Printer, outputFormat string, clusters []ske.Cluster) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(clusters, "", " ") - if err != nil { - return fmt.Errorf("marshal SKE cluster list: %w", err) +func outputResult(p *print.Printer, outputFormat, projectLabel string, clusters []ske.Cluster) error { + return p.OutputResult(outputFormat, clusters, func() error { + if len(clusters) == 0 { + p.Outputf("No clusters found for project %q\n", projectLabel) + return nil } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(clusters, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SKE cluster list: %w", err) - } - p.Outputln(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 { + if c.Extensions != nil && c.Extensions.Observability != nil && c.Extensions.Observability.Enabled { monitoring = "Enabled" } - statusAggregated, kubernetesVersion := "", "" + statusAggregated := "" if c.HasStatus() { statusAggregated = utils.PtrString(c.Status.Aggregated) } - if c.Kubernetes != nil { - kubernetesVersion = utils.PtrString(c.Kubernetes.Version) - } + kubernetesVersion := c.Kubernetes.Version countNodepools := 0 if c.Nodepools != nil { - countNodepools = len(*c.Nodepools) + countNodepools = len(c.Nodepools) } table.AddRow( utils.PtrString(c.Name), @@ -199,5 +176,5 @@ func outputResult(p *print.Printer, outputFormat string, clusters []ske.Cluster) } return nil - } + }) } diff --git a/internal/cmd/ske/cluster/list/list_test.go b/internal/cmd/ske/cluster/list/list_test.go index 2959d6be6..a40ad6984 100644 --- a/internal/cmd/ske/cluster/list/list_test.go +++ b/internal/cmd/ske/cluster/list/list_test.go @@ -4,15 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -20,13 +22,16 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() +const testRegion = "eu01" + func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -38,6 +43,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, Limit: utils.Ptr(int64(10)), @@ -49,7 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiListClustersRequest)) ske.ApiListClustersRequest { - request := testClient.ListClusters(testCtx, testProjectId) + request := testClient.DefaultAPI.ListClusters(testCtx, testProjectId, testRegion) for _, mod := range mods { mod(&request) } @@ -59,6 +65,7 @@ func fixtureRequest(mods ...func(request *ske.ApiListClustersRequest)) ske.ApiLi func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -113,48 +120,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -179,6 +145,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -218,10 +185,10 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.clusters); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, "dummy-projectlabel", tt.args.clusters); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/ske/cluster/maintenance/maintenance.go b/internal/cmd/ske/cluster/maintenance/maintenance.go new file mode 100644 index 000000000..fbf0f16f4 --- /dev/null +++ b/internal/cmd/ske/cluster/maintenance/maintenance.go @@ -0,0 +1,116 @@ +package maintenance + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("maintenance %s", clusterNameArg), + Short: "Trigger maintenance for a SKE cluster", + Long: "Trigger maintenance for a STACKIT Kubernetes Engine (SKE) cluster.", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Trigger maintenance for a SKE cluster with name "my-cluster"`, + "$ stackit ske cluster maintenance my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + prompt := fmt.Sprintf("Are you sure you want to trigger maintenance for %q in project %q?", model.ClusterName, projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("trigger maintenance SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Performing cluster maintenance", func() error { + _, err = wait.TriggerClusterMaintenanceWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for SKE cluster maintenance to complete: %w", err) + } + } + + operationState := "Performed maintenance for" + if model.Async { + operationState = "Triggered maintenance for" + } + params.Printer.Outputf("%s cluster %q\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerMaintenanceRequest { + req := apiClient.DefaultAPI.TriggerMaintenance(ctx, model.ProjectId, model.Region, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/maintenance/maintenance_test.go b/internal/cmd/ske/cluster/maintenance/maintenance_test.go new file mode 100644 index 000000000..5d374d75d --- /dev/null +++ b/internal/cmd/ske/cluster/maintenance/maintenance_test.go @@ -0,0 +1,189 @@ +package maintenance + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testClusterName = "my-cluster" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +func fixtureArgValues(mods ...func([]string)) []string { + argValues := []string{ + testClusterName, + } + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, m := range mods { + m(model) + } + return model +} + +func fixtureRequest(mods ...func(*ske.ApiTriggerMaintenanceRequest)) ske.ApiTriggerMaintenanceRequest { + request := testClient.DefaultAPI.TriggerMaintenance(testCtx, testProjectId, testRegion, testClusterName) + for _, m := range mods { + m(&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: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + delete(fv, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id - empty string", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid uuid format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + 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) + } + } + + if len(tt.argValues) == 0 { + _, err := parseInput(p, cmd, tt.argValues) + if err == nil && !tt.isValid { + t.Fatalf("expected error due to missing args") + } + return + } + + model, err := parseInput(p, 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("input model mismatch:\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerMaintenanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(testCtx, tt.model, testClient) + want := tt.expectedRequest + + diff := cmp.Diff(got, want, + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(want), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("request mismatch:\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/reconcile/reconcile.go b/internal/cmd/ske/cluster/reconcile/reconcile.go new file mode 100644 index 000000000..8e499c418 --- /dev/null +++ b/internal/cmd/ske/cluster/reconcile/reconcile.go @@ -0,0 +1,104 @@ +package reconcile + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("reconcile %s", clusterNameArg), + Short: "Trigger reconcile for a SKE cluster", + Long: "Trigger reconcile for a STACKIT Kubernetes Engine (SKE) cluster.", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Trigger reconcile for a SKE cluster with name "my-cluster"`, + "$ stackit ske cluster reconcile my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("reconcile SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Performing cluster reconciliation", func() error { + _, err = wait.TriggerClusterReconciliationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for SKE cluster reconciliation: %w", err) + } + } + + operationState := "Performed reconciliation for" + if model.Async { + operationState = "Triggered reconcile for" + } + params.Printer.Outputf("%s cluster %q\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerReconcileRequest { + req := apiClient.DefaultAPI.TriggerReconcile(ctx, model.ProjectId, model.Region, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/reconcile/reconcile_test.go b/internal/cmd/ske/cluster/reconcile/reconcile_test.go new file mode 100644 index 000000000..ce5e15bc9 --- /dev/null +++ b/internal/cmd/ske/cluster/reconcile/reconcile_test.go @@ -0,0 +1,189 @@ +package reconcile + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testClusterName = "my-cluster" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +func fixtureArgValues(mods ...func([]string)) []string { + argValues := []string{ + testClusterName, + } + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, m := range mods { + m(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, m := range mods { + m(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiTriggerReconcileRequest)) ske.ApiTriggerReconcileRequest { + request := testClient.DefaultAPI.TriggerReconcile(testCtx, testProjectId, testRegion, testClusterName) + for _, m := range mods { + m(&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: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + delete(fv, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id - empty string", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid uuid format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + 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) + } + } + + if len(tt.argValues) == 0 { + _, err := parseInput(p, cmd, tt.argValues) + if err == nil && !tt.isValid { + t.Fatalf("expected error due to missing args") + } + return + } + + model, err := parseInput(p, 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("input model mismatch:\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerReconcileRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(testCtx, tt.model, testClient) + want := tt.expectedRequest + + diff := cmp.Diff(got, want, + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(want), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("request mismatch:\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/update/update.go b/internal/cmd/ske/cluster/update/update.go index c407b7fb8..bd1facd7a 100644 --- a/internal/cmd/ske/cluster/update/update.go +++ b/internal/cmd/ske/cluster/update/update.go @@ -5,7 +5,8 @@ import ( "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,8 +18,8 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/ske" - "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" ) const ( @@ -33,10 +34,10 @@ type inputModel struct { Payload ske.CreateOrUpdateClusterPayload } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", clusterNameArg), - Short: "Updates an SKE cluster", + Short: "Updates a 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 \"@\".", @@ -45,10 +46,10 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( examples.NewExample( - `Update an SKE cluster using an API payload sourced from the file "./payload.json"`, + `Update a 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`, + `Update a 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`, @@ -58,27 +59,25 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update cluster %q?", model.ClusterName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update cluster %q?", model.ClusterName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Check if cluster exists - exists, err := skeUtils.ClusterExists(ctx, apiClient, model.ProjectId, model.ClusterName) + exists, err := skeUtils.ClusterExists(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName) if err != nil { return err } @@ -96,16 +95,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Updating cluster") - _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient, model.ProjectId, name).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Updating cluster", func() error { + _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, name).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for SKE cluster update: %w", err) } - s.Stop() } - return outputResult(p, model.OutputFormat, model.Async, model.ClusterName, resp) + return outputResult(params.Printer, model.OutputFormat, model.Async, model.ClusterName, resp) }, } configureFlags(cmd) @@ -140,20 +139,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Payload: payload, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiCreateOrUpdateClusterRequest { - req := apiClient.CreateOrUpdateCluster(ctx, model.ProjectId, model.ClusterName) + req := apiClient.DefaultAPI.CreateOrUpdateCluster(ctx, model.ProjectId, model.Region, model.ClusterName) req = req.CreateOrUpdateClusterPayload(model.Payload) return req @@ -164,29 +155,12 @@ func outputResult(p *print.Printer, outputFormat string, async bool, clusterName return fmt.Errorf("cluster is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(cluster, "", " ") - if err != nil { - return fmt.Errorf("marshal SKE cluster: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(cluster, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SKE cluster: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, cluster, func() error { operationState := "Updated" if async { operationState = "Triggered update of" } p.Info("%s cluster %q\n", operationState, clusterName) return nil - } + }) } diff --git a/internal/cmd/ske/cluster/update/update_test.go b/internal/cmd/ske/cluster/update/update_test.go index 53bce8b78..e4cad4e35 100644 --- a/internal/cmd/ske/cluster/update/update_test.go +++ b/internal/cmd/ske/cluster/update/update_test.go @@ -5,14 +5,17 @@ import ( "testing" "time" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/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" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -20,51 +23,67 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() var testClusterName = "cluster" +const testRegion = "eu01" + var testPayload = ske.CreateOrUpdateClusterPayload{ - Kubernetes: &ske.Kubernetes{ - Version: utils.Ptr("1.25.15"), + Kubernetes: ske.Kubernetes{ + Version: "1.25.15", + AdditionalProperties: map[string]any{}, }, - Nodepools: &[]ske.Nodepool{ + Nodepools: []ske.Nodepool{ { - Name: utils.Ptr("np-name"), - Machine: &ske.Machine{ - Image: &ske.Image{ - Name: utils.Ptr("flatcar"), - Version: utils.Ptr("3760.2.1"), + Name: "np-name", + Machine: ske.Machine{ + Image: ske.Image{ + Name: "flatcar", + Version: "3760.2.1", + AdditionalProperties: map[string]any{}, }, - Type: utils.Ptr("b1.2"), + Type: "b1.2", + AdditionalProperties: map[string]any{}, + }, + Minimum: int32(1), + Maximum: int32(2), + MaxSurge: utils.Ptr(int32(1)), + Volume: ske.Volume{ + Type: utils.Ptr("storage_premium_perf0"), + Size: int32(40), + AdditionalProperties: map[string]any{}, }, - 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("containerd"), + AdditionalProperties: map[string]any{}, }, - AvailabilityZones: &[]string{"eu01-3"}, - Cri: &ske.CRI{Name: utils.Ptr("cri")}, + AdditionalProperties: map[string]any{}, }, }, Extensions: &ske.Extension{ Acl: &ske.ACL{ - Enabled: utils.Ptr(true), - AllowedCidrs: &[]string{"0.0.0.0/0"}, + Enabled: true, + AllowedCidrs: []string{"0.0.0.0/0"}, + AdditionalProperties: map[string]any{}, }, + AdditionalProperties: map[string]any{}, }, Maintenance: &ske.Maintenance{ - AutoUpdate: &ske.MaintenanceAutoUpdate{ - KubernetesVersion: utils.Ptr(true), - MachineImageVersion: utils.Ptr(true), + AutoUpdate: ske.MaintenanceAutoUpdate{ + KubernetesVersion: utils.Ptr(true), + MachineImageVersion: utils.Ptr(true), + AdditionalProperties: map[string]any{}, }, - TimeWindow: &ske.TimeWindow{ - End: utils.Ptr(time.Date(0, 1, 1, 5, 0, 0, 0, time.FixedZone("test-zone", 2*60*60))), - Start: utils.Ptr(time.Date(0, 1, 1, 3, 0, 0, 0, time.FixedZone("test-zone", 2*60*60))), + TimeWindow: ske.TimeWindow{ + End: time.Date(0, 1, 1, 5, 0, 0, 0, time.FixedZone("test-zone", 2*60*60)), + Start: time.Date(0, 1, 1, 3, 0, 0, 0, time.FixedZone("test-zone", 2*60*60)), + AdditionalProperties: map[string]any{}, }, + AdditionalProperties: map[string]any{}, }, + AdditionalProperties: map[string]any{}, } func fixtureArgValues(mods ...func(argValues []string)) []string { @@ -79,9 +98,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, payloadFlag: `{ - "name": "cli-jp", "kubernetes": { "version": "1.25.15" }, @@ -99,7 +118,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st "maximum": 2, "maxSurge": 1, "volume": { "type": "storage_premium_perf0", "size": 40 }, - "cri": { "name": "cri" }, + "cri": { "name": "containerd" }, "availabilityZones": ["eu01-3"] } ], @@ -126,6 +145,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ClusterName: testClusterName, @@ -138,7 +158,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiCreateOrUpdateClusterRequest)) ske.ApiCreateOrUpdateClusterRequest { - request := testClient.CreateOrUpdateCluster(testCtx, testProjectId, fixtureInputModel().ClusterName) + request := testClient.DefaultAPI.CreateOrUpdateCluster(testCtx, testProjectId, testRegion, fixtureInputModel().ClusterName) request = request.CreateOrUpdateClusterPayload(testPayload) for _, mod := range mods { mod(&request) @@ -215,54 +235,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -288,6 +261,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -322,7 +296,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.clusterName, tt.args.cluster); (err != nil) != tt.wantErr { diff --git a/internal/cmd/ske/cluster/wakeup/wakeup.go b/internal/cmd/ske/cluster/wakeup/wakeup.go new file mode 100644 index 000000000..2ed99d8ad --- /dev/null +++ b/internal/cmd/ske/cluster/wakeup/wakeup.go @@ -0,0 +1,104 @@ +package wakeup + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("wakeup %s", clusterNameArg), + Short: "Trigger wakeup from hibernation for a SKE cluster", + Long: "Trigger wakeup from hibernation for a STACKIT Kubernetes Engine (SKE) cluster.", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Trigger wakeup from hibernation for a SKE cluster with name "my-cluster"`, + "$ stackit ske cluster wakeup my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("wakeup SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Performing cluster wakeup", func() error { + _, err = wait.TriggerClusterWakeupWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for SKE cluster wakeup: %w", err) + } + } + + operationState := "Performed wakeup of" + if model.Async { + operationState = "Triggered wakeup of" + } + params.Printer.Outputf("%s cluster %q\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerWakeupRequest { + req := apiClient.DefaultAPI.TriggerWakeup(ctx, model.ProjectId, model.Region, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/wakeup/wakeup_test.go b/internal/cmd/ske/cluster/wakeup/wakeup_test.go new file mode 100644 index 000000000..a729af751 --- /dev/null +++ b/internal/cmd/ske/cluster/wakeup/wakeup_test.go @@ -0,0 +1,187 @@ +package wakeup + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testClusterName = "my-cluster" +) + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} +var testProjectId = uuid.NewString() + +func fixtureArgValues(mods ...func([]string)) []string { + argValues := []string{testClusterName} + for _, m := range mods { + m(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(map[string]string)) map[string]string { + flags := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, m := range mods { + m(flags) + } + return flags +} + +func fixtureInputModel(mods ...func(*inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + ClusterName: testClusterName, + } + for _, m := range mods { + m(model) + } + return model +} + +func fixtureRequest(mods ...func(*ske.ApiTriggerWakeupRequest)) ske.ApiTriggerWakeupRequest { + req := testClient.DefaultAPI.TriggerWakeup(testCtx, testProjectId, testRegion, testClusterName) + for _, m := range mods { + m(&req) + } + return req +} + +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: "missing project id", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + delete(fv, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "invalid project id - empty string", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "invalid uuid format", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(fv map[string]string) { + fv[globalflags.ProjectIdFlag] = "not-a-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + 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) + } + } + + if len(tt.argValues) == 0 { + _, err := parseInput(p, cmd, tt.argValues) + if err == nil && !tt.isValid { + t.Fatalf("expected failure due to missing args") + } + return + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("unexpected error: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("input model mismatch:\n%s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerWakeupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := buildRequest(testCtx, tt.model, testClient) + want := tt.expectedRequest + + diff := cmp.Diff(got, want, + cmpopts.EquateComparable(testCtx), + cmp.AllowUnexported(want), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("request mismatch:\n%s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go b/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go index f9936692e..734a89735 100644 --- a/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go +++ b/internal/cmd/ske/credentials/complete-rotation/complete_rotation.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +15,8 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/ske" - "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" ) const ( @@ -26,7 +28,7 @@ type inputModel struct { ClusterName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("complete-rotation %s", clusterNameArg), Short: "Completes the rotation of the credentials associated to a SKE cluster", @@ -39,7 +41,7 @@ func NewCmd(p *print.Printer) *cobra.Command { " $ stackit ske kubeconfig create my-cluster", "If you haven't, please start the process by running:", " $ stackit ske credentials start-rotation my-cluster", - "For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html", + "For more information, visit: https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/rotate-ske-credentials/", ), Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( @@ -56,23 +58,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to complete the rotation of the credentials for SKE cluster %q?", model.ClusterName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to complete the rotation of the credentials for SKE cluster %q?", model.ClusterName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -84,21 +84,21 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Completing credentials rotation") - _, err = wait.CompleteCredentialsRotationWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Completing credentials rotation", func() error { + _, err = wait.CompleteCredentialsRotationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for completing SKE credentials rotation %w", err) } - s.Stop() } operationState := "Rotation of credentials is completed" if model.Async { operationState = "Triggered completion of credentials rotation" } - p.Info("%s for cluster %q\n", operationState, model.ClusterName) - p.Warn("Consider updating your kubeconfig with the new credentials, create a new kubeconfig by running:\n $ stackit ske kubeconfig create %s\n", model.ClusterName) + params.Printer.Info("%s for cluster %q\n", operationState, model.ClusterName) + params.Printer.Warn("Consider updating your kubeconfig with the new credentials, create a new kubeconfig by running:\n $ stackit ske kubeconfig create %s\n", model.ClusterName) return nil }, } @@ -118,19 +118,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ClusterName: clusterName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiCompleteCredentialsRotationRequest { - req := apiClient.CompleteCredentialsRotation(ctx, model.ProjectId, model.ClusterName) + req := apiClient.DefaultAPI.CompleteCredentialsRotation(ctx, model.ProjectId, model.Region, model.ClusterName) return req } diff --git a/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go b/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go index 6b840c3e7..9ef9dc2f8 100644 --- a/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go +++ b/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go @@ -5,12 +5,12 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -18,10 +18,12 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() var testClusterName = "cluster" +const testRegion = "eu01" + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testClusterName, @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ClusterName: testClusterName, @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiCompleteCredentialsRotationRequest)) ske.ApiCompleteCredentialsRotationRequest { - request := testClient.CompleteCredentialsRotation(testCtx, testProjectId, testClusterName) + request := testClient.DefaultAPI.CompleteCredentialsRotation(testCtx, testProjectId, testRegion, testClusterName) for _, mod := range mods { mod(&request) } @@ -125,54 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -197,6 +154,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) 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 index 6fdfb1fc3..13218d2fa 100644 --- a/internal/cmd/ske/credentials/credentials.go +++ b/internal/cmd/ske/credentials/credentials.go @@ -4,13 +4,13 @@ import ( completerotation "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/complete-rotation" startrotation "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/start-rotation" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", Short: "Provides functionality for SKE credentials", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(startrotation.NewCmd(p)) - cmd.AddCommand(completerotation.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(startrotation.NewCmd(params)) + cmd.AddCommand(completerotation.NewCmd(params)) } diff --git a/internal/cmd/ske/credentials/start-rotation/start_rotation.go b/internal/cmd/ske/credentials/start-rotation/start_rotation.go index b11c42623..ea2933fab 100644 --- a/internal/cmd/ske/credentials/start-rotation/start_rotation.go +++ b/internal/cmd/ske/credentials/start-rotation/start_rotation.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,8 +15,8 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/ske" - "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api/wait" ) const ( @@ -26,7 +28,7 @@ type inputModel struct { ClusterName string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("start-rotation %s", clusterNameArg), Short: "Starts the rotation of the credentials associated to a SKE cluster", @@ -43,7 +45,7 @@ func NewCmd(p *print.Printer) *cobra.Command { " $ stackit ske kubeconfig create my-cluster", "Complete the rotation by running:", " $ stackit ske credentials complete-rotation my-cluster", - "For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html", + "For more information, visit: https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/rotate-ske-credentials/", ), Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( @@ -59,23 +61,21 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to start the rotation of the credentials for SKE cluster %q?", model.ClusterName) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to start the rotation of the credentials for SKE cluster %q?", model.ClusterName) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -87,21 +87,21 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Starting credentials rotation") - _, err = wait.StartCredentialsRotationWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Starting credentials rotation", func() error { + _, err = wait.StartCredentialsRotationWaitHandler(ctx, apiClient.DefaultAPI, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for start SKE credentials rotation %w", err) } - s.Stop() } operationState := "Rotation of credentials is ready to be completed" if model.Async { operationState = "Triggered start of credentials rotation" } - p.Info("%s for cluster %q\n", operationState, model.ClusterName) - p.Info("Complete the rotation by running:\n $ stackit ske credentials complete-rotation %s\n", model.ClusterName) + params.Printer.Info("%s for cluster %q\n", operationState, model.ClusterName) + params.Printer.Info("Complete the rotation by running:\n $ stackit ske credentials complete-rotation %s\n", model.ClusterName) return nil }, } @@ -121,19 +121,11 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu ClusterName: clusterName, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiStartCredentialsRotationRequest { - req := apiClient.StartCredentialsRotation(ctx, model.ProjectId, model.ClusterName) + req := apiClient.DefaultAPI.StartCredentialsRotation(ctx, model.ProjectId, model.Region, model.ClusterName) return req } diff --git a/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go b/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go index dc5643eaa..e3fae33d2 100644 --- a/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go +++ b/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go @@ -5,12 +5,12 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -18,10 +18,12 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() var testClusterName = "cluster" +const testRegion = "eu01" + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testClusterName, @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ClusterName: testClusterName, @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiStartCredentialsRotationRequest)) ske.ApiStartCredentialsRotationRequest { - request := testClient.StartCredentialsRotation(testCtx, testProjectId, testClusterName) + request := testClient.DefaultAPI.StartCredentialsRotation(testCtx, testProjectId, testRegion, testClusterName) for _, mod := range mods { mod(&request) } @@ -125,54 +129,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -197,6 +154,7 @@ func TestBuildRequest(t *testing.T) { diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) 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 index 6694b04a7..07fedda34 100644 --- a/internal/cmd/ske/describe/describe.go +++ b/internal/cmd/ske/describe/describe.go @@ -2,11 +2,13 @@ package describe import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,14 +18,13 @@ import ( skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" ) type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "describe", Short: "Shows overall details regarding SKE", @@ -34,14 +35,14 @@ func NewCmd(p *print.Printer) *cobra.Command { `Get details regarding SKE functionality on your project`, "$ stackit ske describe"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -53,13 +54,13 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read SKE project details: %w", err) } - return outputResult(p, model.OutputFormat, resp, model.ProjectId) + return outputResult(params.Printer, model.OutputFormat, resp, model.ProjectId) }, } return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -69,15 +70,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } @@ -91,24 +84,7 @@ func outputResult(p *print.Printer, outputFormat string, project *serviceenablem return fmt.Errorf("project is nil") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(project, "", " ") - if err != nil { - return fmt.Errorf("marshal SKE project details: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(project, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SKE project details: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, project, func() error { table := tables.NewTable() table.AddRow("ID", projectId) table.AddSeparator() @@ -121,5 +97,5 @@ func outputResult(p *print.Printer, outputFormat string, project *serviceenablem } return nil - } + }) } diff --git a/internal/cmd/ske/describe/describe_test.go b/internal/cmd/ske/describe/describe_test.go index 839ae512e..0af19c957 100644 --- a/internal/cmd/ske/describe/describe_test.go +++ b/internal/cmd/ske/describe/describe_test.go @@ -4,15 +4,18 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" serviceEnablementUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils" - "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/spf13/cobra" ) type testCtxKey struct{} @@ -58,6 +61,7 @@ func fixtureRequest(mods ...func(request *serviceenablement.ApiGetServiceStatusR func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -98,46 +102,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -196,7 +161,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.project, tt.args.projectId); (err != nil) != tt.wantErr { diff --git a/internal/cmd/ske/disable/disable.go b/internal/cmd/ske/disable/disable.go index d00a56715..43c103e7b 100644 --- a/internal/cmd/ske/disable/disable.go +++ b/internal/cmd/ske/disable/disable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -23,7 +25,7 @@ type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "disable", Short: "Disables SKE for a project", @@ -34,31 +36,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Disable SKE functionality for your project, deleting all associated clusters`, "$ stackit ske disable"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to disable SKE for project %q? (This will delete all associated clusters)", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to disable SKE for project %q? (This will delete all associated clusters)", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -70,27 +70,27 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Disabling SKE") - _, err = wait.DisableServiceWaitHandler(ctx, apiClient, model.Region, model.ProjectId, utils.SKEServiceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Disabling SKE", func() error { + _, err = wait.DisableServiceWaitHandler(ctx, apiClient, model.Region, model.ProjectId, utils.SKEServiceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for SKE disabling: %w", err) } - s.Stop() } operationState := "Disabled" if model.Async { operationState = "Triggered disablement of" } - p.Info("%s SKE for project %q\n", operationState, projectLabel) + params.Printer.Info("%s SKE for project %q\n", operationState, projectLabel) return nil }, } return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -100,15 +100,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/ske/disable/disable_test.go b/internal/cmd/ske/disable/disable_test.go index b377dc366..978e383ed 100644 --- a/internal/cmd/ske/disable/disable_test.go +++ b/internal/cmd/ske/disable/disable_test.go @@ -5,13 +5,12 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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/serviceenablement" ) @@ -58,6 +57,7 @@ func fixtureRequest(mods ...func(request *serviceenablement.ApiDisableServiceReg func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -98,46 +98,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/ske/enable/enable.go b/internal/cmd/ske/enable/enable.go index 9ab8ed7bf..ea83a337b 100644 --- a/internal/cmd/ske/enable/enable.go +++ b/internal/cmd/ske/enable/enable.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -23,7 +25,7 @@ type inputModel struct { *globalflags.GlobalFlagModel } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "enable", Short: "Enables SKE for a project", @@ -34,31 +36,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `Enable SKE functionality for your project`, "$ stackit ske enable"), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to enable SKE for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to enable SKE for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -70,27 +70,27 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Enabling SKE") - _, err = wait.EnableServiceWaitHandler(ctx, apiClient, model.Region, model.ProjectId, utils.SKEServiceId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Enabling SKE", func() error { + _, err = wait.EnableServiceWaitHandler(ctx, apiClient, model.Region, model.ProjectId, utils.SKEServiceId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for SKE enabling: %w", err) } - s.Stop() } operationState := "Enabled" if model.Async { operationState = "Triggered enablement of" } - p.Info("%s SKE for project %q\n", operationState, projectLabel) + params.Printer.Info("%s SKE for project %q\n", operationState, projectLabel) return nil }, } return cmd } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -100,15 +100,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { GlobalFlagModel: globalFlags, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } diff --git a/internal/cmd/ske/enable/enable_test.go b/internal/cmd/ske/enable/enable_test.go index d46ec2d07..add7b850b 100644 --- a/internal/cmd/ske/enable/enable_test.go +++ b/internal/cmd/ske/enable/enable_test.go @@ -5,13 +5,12 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "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/serviceenablement" ) @@ -58,6 +57,7 @@ func fixtureRequest(mods ...func(request *serviceenablement.ApiEnableServiceRegi func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -98,46 +98,7 @@ func TestParseInput(t *testing.T) { 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) - } - - p := print.NewPrinter() - model, err := parseInput(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/ske/kubeconfig/create/create.go b/internal/cmd/ske/kubeconfig/create/create.go index 8677b033c..96093f901 100644 --- a/internal/cmd/ske/kubeconfig/create/create.go +++ b/internal/cmd/ske/kubeconfig/create/create.go @@ -5,7 +5,10 @@ import ( "encoding/json" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +20,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/ske" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) const ( @@ -27,6 +30,7 @@ const ( expirationFlag = "expiration" filepathFlag = "filepath" loginFlag = "login" + idpFlag = "idp" overwriteFlag = "overwrite" ) @@ -37,65 +41,70 @@ type inputModel struct { ExpirationTime *string Filepath *string Login bool + IDP bool Overwrite bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("create %s", clusterNameArg), - Short: "Creates or update a kubeconfig for an SKE cluster", + Short: "Creates or update a kubeconfig for a SKE cluster", Long: fmt.Sprintf("%s\n\n%s\n%s\n%s\n%s", - "Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster, if the config exits in the kubeconfig file the information will be updated.", - "By default, the kubeconfig information of the SKE cluster is merged into the default kubeconfig file of the current user. If the kubeconfig file doesn't exist, a new one will be created.", - "You can override this behavior by specifying a custom filepath with the --filepath flag.\n", + "Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster. By default an admin kubeconfig is created. Use the `--idp` option to create an IDP kubeconfig that authenticates via the STACKIT IDP.", + "If the config exists in the kubeconfig file the information will be updated. By default, the kubeconfig information of the SKE cluster is merged into the default kubeconfig file of the current user. If the kubeconfig file doesn't exist, a new one will be created.", + "You can override this behavior by specifying a custom filepath using the --filepath flag or by setting the KUBECONFIG env variable (fallback).\n", "An expiration time can be set for the kubeconfig. The expiration time is set in seconds(s), minutes(m), hours(h), days(d) or months(M). Default is 1h.\n", "Note that the format is , e.g. 30d for 30 days and you can't combine units."), Args: args.SingleArg(clusterNameArg, nil), Example: examples.Build( examples.NewExample( - `Create or update a kubeconfig for the SKE cluster with name "my-cluster. If the config exits in the kubeconfig file the information will be updated."`, - "$ stackit ske kubeconfig create my-cluster"), - examples.NewExample( - `Get a login kubeconfig for the SKE cluster with name "my-cluster". `+ - "This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.", + `Get a short-lived admin kubeconfig for the SKE cluster with name "my-cluster". `+ + "This kubeconfig does not contain any credentials and instead obtains valid admin credentials via the `stackit ske kubeconfig login` command.", "$ stackit ske kubeconfig create my-cluster --login"), examples.NewExample( - `Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 30 days. If the config exits in the kubeconfig file the information will be updated.`, + `Get an IDP kubeconfig for the SKE cluster with name "my-cluster". `+ + "This kubeconfig does not grant permissions in the cluster by default and obtains credentials on-demand via the `stackit ske kubeconfig login` command.", + "$ stackit ske kubeconfig create my-cluster --idp"), + examples.NewExample( + `Create or update a short-lived admin kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath. If the config exits in the kubeconfig file the information will be updated.`, + "$ stackit ske kubeconfig create my-cluster --login --filepath /path/to/config"), + examples.NewExample( + `Create or update an admin kubeconfig for the SKE cluster with name "my-cluster". If the config exits in the kubeconfig file the information will be updated."`, + "$ stackit ske kubeconfig create my-cluster"), + examples.NewExample( + `Create an admin kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 30 days. If the config exits in the kubeconfig file the information will be updated.`, "$ stackit ske kubeconfig create my-cluster --expiration 30d"), examples.NewExample( - `Create or update a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 2 months. If the config exits in the kubeconfig file the information will be updated.`, + `Create or update an admin kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 2 months. If the config exits in the kubeconfig file the information will be updated.`, "$ stackit ske kubeconfig create my-cluster --expiration 2M"), examples.NewExample( - `Create or update a kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath. If the config exits in the kubeconfig file the information will be updated.`, - "$ stackit ske kubeconfig create my-cluster --filepath /path/to/config"), - examples.NewExample( - `Get a kubeconfig for the SKE cluster with name "my-cluster" without writing it to a file and format the output as json`, + `Get an admin kubeconfig for the SKE cluster with name "my-cluster" without writing it to a file and format the output as json`, "$ stackit ske kubeconfig create my-cluster --disable-writing --output-format json"), examples.NewExample( - `Create a kubeconfig for the SKE cluster with name "my-cluster. It will OVERWRITE your current kubeconfig file."`, + `Create an admin kubeconfig for the SKE cluster with name "my-cluster". It will OVERWRITE your current kubeconfig file.`, "$ stackit ske kubeconfig create my-cluster --overwrite true"), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - if !model.AssumeYes && !model.DisableWriting { + if !model.DisableWriting { var prompt string if model.Overwrite { prompt = fmt.Sprintf("Are you sure you want to create a kubeconfig for SKE cluster %q? This will OVERWRITE your current kubeconfig file, if it exists.", model.ClusterName) } else { - prompt = fmt.Sprintf("Are you sure you want to update your kubeconfig for SKE cluster %q? This will update your kubeconfig file. \nIf it the kubeconfig file doesn't exists, it will create a new one.", model.ClusterName) + prompt = fmt.Sprintf("Are you sure you want to update your kubeconfig for SKE cluster %q? This will update your kubeconfig file. \nIf the kubeconfig file does not exists, it will create a new one.", model.ClusterName) } - err = p.PromptForConfirmation(prompt) + err = params.Printer.PromptForConfirmation(prompt) if err != nil { return err } @@ -106,22 +115,10 @@ func NewCmd(p *print.Printer) *cobra.Command { kubeconfig string respKubeconfig *ske.Kubeconfig respLogin *ske.LoginKubeconfig + respIDP *ske.IDPKubeconfig ) - if !model.Login { - req, err := buildRequestCreate(ctx, model, apiClient) - if err != nil { - return fmt.Errorf("build kubeconfig create request: %w", err) - } - respKubeconfig, err = req.Execute() - if err != nil { - return fmt.Errorf("create kubeconfig for SKE cluster: %w", err) - } - if respKubeconfig.Kubeconfig == nil { - return fmt.Errorf("no kubeconfig returned from the API") - } - kubeconfig = *respKubeconfig.Kubeconfig - } else { + if model.Login { req, err := buildRequestLogin(ctx, model, apiClient) if err != nil { return fmt.Errorf("build login kubeconfig create request: %w", err) @@ -134,6 +131,32 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("no login kubeconfig returned from the API") } kubeconfig = *respLogin.Kubeconfig + } else if model.IDP { + req, err := buildRequestIDP(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build idp kubeconfig create request: %w", err) + } + respIDP, err = req.Execute() + if err != nil { + return fmt.Errorf("create idp kubeconfig for SKE cluster: %w", err) + } + if respIDP.Kubeconfig == nil { + return fmt.Errorf("no idp kubeconfig returned from the API") + } + kubeconfig = *respIDP.Kubeconfig + } else { + req, err := buildRequestCreate(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build kubeconfig create request: %w", err) + } + respKubeconfig, err = req.Execute() + if err != nil { + return fmt.Errorf("create kubeconfig for SKE cluster: %w", err) + } + if respKubeconfig.Kubeconfig == nil { + return fmt.Errorf("no kubeconfig returned from the API") + } + kubeconfig = *respKubeconfig.Kubeconfig } // Create the config file @@ -156,10 +179,10 @@ func NewCmd(p *print.Printer) *cobra.Command { if err != nil { return fmt.Errorf("write kubeconfig file: %w", err) } - p.Outputf("\nSet kubectl context to %s with: kubectl config use-context %s\n", model.ClusterName, model.ClusterName) + params.Printer.Outputf("\nSet kubectl context to %s with: kubectl config use-context %s\n", model.ClusterName, model.ClusterName) } - return outputResult(p, model.OutputFormat, model.ClusterName, kubeconfigPath, respKubeconfig, respLogin) + return outputResult(params.Printer, model.OutputFormat, model.ClusterName, kubeconfigPath, respKubeconfig, respLogin, respIDP) }, } configureFlags(cmd) @@ -168,11 +191,12 @@ func NewCmd(p *print.Printer) *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(disableWritingFlag, false, fmt.Sprintf("Disable the writing of kubeconfig. Set the output format to json or yaml using the --%s flag to display the kubeconfig.", globalflags.OutputFormatFlag)) - cmd.Flags().BoolP(loginFlag, "l", false, "Create a login kubeconfig that obtains valid credentials via the STACKIT CLI. This flag is mutually exclusive with the expiration flag.") - cmd.Flags().String(filepathFlag, "", "Path to create the kubeconfig file. By default, the kubeconfig is created as 'config' in the .kube folder, in the user's home directory.") + cmd.Flags().BoolP(loginFlag, "l", false, "Create a short-lived admin kubeconfig that obtains valid credentials via the STACKIT CLI. This flag is mutually exclusive with the expiration flag.") + cmd.Flags().Bool(idpFlag, false, "Create a non-admin kubeconfig that uses the STACKIT IDP to obtain credentials.") + cmd.Flags().String(filepathFlag, "", "Path to create the kubeconfig file. Will fall back to KUBECONFIG env variable if not set. In case both aren't set, the kubeconfig is created as file named 'config' in the .kube folder in the user's home directory.") cmd.Flags().StringP(expirationFlag, "e", "", "Expiration time for the kubeconfig in seconds(s), minutes(m), hours(h), days(d) or months(M). Example: 30d. By default, expiration time is 1h") cmd.Flags().Bool(overwriteFlag, false, "Overwrite the kubeconfig file.") - cmd.MarkFlagsMutuallyExclusive(loginFlag, expirationFlag) + cmd.MarkFlagsMutuallyExclusive(loginFlag, expirationFlag, idpFlag) } func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { @@ -211,23 +235,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Filepath: flags.FlagToStringPointer(p, cmd, filepathFlag), GlobalFlagModel: globalFlags, Login: flags.FlagToBoolValue(p, cmd, loginFlag), + IDP: flags.FlagToBoolValue(p, cmd, idpFlag), Overwrite: flags.FlagToBoolValue(p, cmd, overwriteFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequestCreate(ctx context.Context, model *inputModel, apiClient *ske.APIClient) (ske.ApiCreateKubeconfigRequest, error) { - req := apiClient.CreateKubeconfig(ctx, model.ProjectId, model.ClusterName) + req := apiClient.DefaultAPI.CreateKubeconfig(ctx, model.ProjectId, model.Region, model.ClusterName) payload := ske.CreateKubeconfigPayload{} @@ -238,11 +255,15 @@ func buildRequestCreate(ctx context.Context, model *inputModel, apiClient *ske.A return req.CreateKubeconfigPayload(payload), nil } +func buildRequestIDP(ctx context.Context, model *inputModel, apiClient *ske.APIClient) (ske.ApiGetIDPKubeconfigRequest, error) { + return apiClient.DefaultAPI.GetIDPKubeconfig(ctx, model.ProjectId, model.Region, model.ClusterName), nil +} + func buildRequestLogin(ctx context.Context, model *inputModel, apiClient *ske.APIClient) (ske.ApiGetLoginKubeconfigRequest, error) { - return apiClient.GetLoginKubeconfig(ctx, model.ProjectId, model.ClusterName), nil + return apiClient.DefaultAPI.GetLoginKubeconfig(ctx, model.ProjectId, model.Region, model.ClusterName), nil } -func outputResult(p *print.Printer, outputFormat, clusterName, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.LoginKubeconfig) error { +func outputResult(p *print.Printer, outputFormat, clusterName, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.LoginKubeconfig, respIDP *ske.IDPKubeconfig) error { switch outputFormat { case print.JSONOutputFormat: var err error @@ -251,6 +272,8 @@ func outputResult(p *print.Printer, outputFormat, clusterName, kubeconfigPath st details, err = json.MarshalIndent(respKubeconfig, "", " ") } else if respLogin != nil { details, err = json.MarshalIndent(respLogin, "", " ") + } else if respIDP != nil { + details, err = json.MarshalIndent(respIDP, "", " ") } if err != nil { return fmt.Errorf("marshal SKE Kubeconfig: %w", err) @@ -265,6 +288,8 @@ func outputResult(p *print.Printer, outputFormat, clusterName, kubeconfigPath st details, err = yaml.MarshalWithOptions(respKubeconfig, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) } else if respLogin != nil { details, err = yaml.MarshalWithOptions(respLogin, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + } else if respIDP != nil { + details, err = yaml.MarshalWithOptions(respIDP, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) } if err != nil { return fmt.Errorf("marshal SKE Kubeconfig: %w", err) diff --git a/internal/cmd/ske/kubeconfig/create/create_test.go b/internal/cmd/ske/kubeconfig/create/create_test.go index 9743067f8..a7772f6c8 100644 --- a/internal/cmd/ske/kubeconfig/create/create_test.go +++ b/internal/cmd/ske/kubeconfig/create/create_test.go @@ -4,13 +4,17 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/ske" ) var projectIdFlag = globalflags.ProjectIdFlag @@ -18,10 +22,12 @@ var projectIdFlag = globalflags.ProjectIdFlag type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() var testClusterName = "cluster" +const testRegion = "eu01" + func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ testClusterName, @@ -34,7 +40,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, ClusterName: testClusterName, @@ -57,7 +65,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) ske.ApiCreateKubeconfigRequest { - request := testClient.CreateKubeconfig(testCtx, testProjectId, testClusterName) + request := testClient.DefaultAPI.CreateKubeconfig(testCtx, testProjectId, testRegion, testClusterName) request = request.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{}) for _, mod := range mods { mod(&request) @@ -65,6 +73,14 @@ func fixtureRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) ske.A return request } +func fixtureRequestLogin() ske.ApiGetLoginKubeconfigRequest { + return testClient.DefaultAPI.GetLoginKubeconfig(testCtx, testProjectId, testRegion, testClusterName) +} + +func fixtureRequestIDP() ske.ApiGetIDPKubeconfigRequest { + return testClient.DefaultAPI.GetIDPKubeconfig(testCtx, testProjectId, testRegion, testClusterName) +} + func TestParseInput(t *testing.T) { tests := []struct { description string @@ -102,6 +118,17 @@ func TestParseInput(t *testing.T) { model.Login = true }), }, + { + description: "idp", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["idp"] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.IDP = true + }), + }, { description: "custom filepath", argValues: fixtureArgValues(), @@ -202,54 +229,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -278,18 +258,37 @@ func TestBuildRequestCreate(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { request, _ := buildRequestCreate(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) - } + assertNoDiff(t, request, tt.expectedRequest) }) } } +func assertNoDiff(t *testing.T, actual, expected any) { + t.Helper() + diff := cmp.Diff(actual, expected, + cmp.AllowUnexported(expected), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestBuildRequestLogin(t *testing.T) { + model := fixtureInputModel() + expectedRequest := fixtureRequestLogin() + request, _ := buildRequestLogin(testCtx, model, testClient) + assertNoDiff(t, request, expectedRequest) +} + +func TestBuildRequestIDP(t *testing.T) { + model := fixtureInputModel() + expectedRequest := fixtureRequestIDP() + request, _ := buildRequestIDP(testCtx, model, testClient) + assertNoDiff(t, request, expectedRequest) +} + func Test_outputResult(t *testing.T) { type args struct { outputFormat string @@ -297,6 +296,7 @@ func Test_outputResult(t *testing.T) { kubeconfigPath string respKubeconfig *ske.Kubeconfig respLogin *ske.LoginKubeconfig + respIDP *ske.IDPKubeconfig } tests := []struct { name string @@ -322,12 +322,19 @@ func Test_outputResult(t *testing.T) { }, wantErr: false, }, + { + name: "missing idp", + args: args{ + respIDP: &ske.IDPKubeconfig{}, + }, + wantErr: false, + }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := outputResult(p, tt.args.outputFormat, tt.args.clusterName, tt.args.kubeconfigPath, tt.args.respKubeconfig, tt.args.respLogin); (err != nil) != tt.wantErr { + if err := outputResult(p, tt.args.outputFormat, tt.args.clusterName, tt.args.kubeconfigPath, tt.args.respKubeconfig, tt.args.respLogin, tt.args.respIDP); (err != nil) != tt.wantErr { t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) } }) diff --git a/internal/cmd/ske/kubeconfig/kubeconfig.go b/internal/cmd/ske/kubeconfig/kubeconfig.go index 44803f14b..e1fb827c2 100644 --- a/internal/cmd/ske/kubeconfig/kubeconfig.go +++ b/internal/cmd/ske/kubeconfig/kubeconfig.go @@ -4,13 +4,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/ske/kubeconfig/create" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/kubeconfig/login" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "kubeconfig", Short: "Provides functionality for SKE kubeconfig", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(login.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(login.NewCmd(params)) } diff --git a/internal/cmd/ske/kubeconfig/login/login.go b/internal/cmd/ske/kubeconfig/login/login.go index 817b33ca4..268831202 100644 --- a/internal/cmd/ske/kubeconfig/login/login.go +++ b/internal/cmd/ske/kubeconfig/login/login.go @@ -8,53 +8,66 @@ import ( "encoding/pem" "errors" "fmt" + "net/http" "os" "strconv" "time" - "github.com/stackitcloud/stackit-cli/internal/pkg/cache" - "k8s.io/client-go/rest" - - "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/examples" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/ske" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/auth/exec" "k8s.io/client-go/tools/clientcmd" + + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/cache" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" ) const ( - expirationSeconds = 30 * 60 // 30 min - refreshBeforeDuration = 15 * time.Minute // 15 min + expirationSeconds = 30 * 60 // 30 min + refreshBeforeDuration = 15 * time.Minute // 15 min + refreshTokenBeforeDuration = 5 * time.Minute // 5 min + + idpFlag = "idp" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Login plugin for kubernetes clients", Long: fmt.Sprintf("%s\n%s\n%s", "Login plugin for kubernetes clients, that creates short-lived credentials to authenticate against a STACKIT Kubernetes Engine (SKE) cluster.", - "First you need to obtain a kubeconfig for use with the login command (first example).", - "Secondly you use the kubeconfig with your chosen Kubernetes client (second example), the client will automatically retrieve the credentials via the STACKIT CLI.", + "First you need to obtain a kubeconfig for use with the login command (first or second example).", + "Secondly you use the kubeconfig with your chosen Kubernetes client (third example), the client will automatically retrieve the credentials via the STACKIT CLI.", ), Args: args.NoArgs, Example: examples.Build( examples.NewExample( - `Get a login kubeconfig for the SKE cluster with name "my-cluster". `+ - "This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.", + `Get an admin, login kubeconfig for the SKE cluster with name "my-cluster". `+ + "This kubeconfig does not contain any credentials and instead obtains valid admin credentials via the `stackit ske kubeconfig login` command.", "$ stackit ske kubeconfig create my-cluster --login"), + examples.NewExample( + `Get an IDP kubeconfig for the SKE cluster with name "my-cluster". `+ + "This kubeconfig does not contain any credentials and instead obtains valid credentials via the `stackit ske kubeconfig login` command.", + "$ stackit ske kubeconfig create my-cluster --idp"), examples.NewExample( "Use the previously saved kubeconfig to authenticate to the SKE cluster, in this case with kubectl.", "$ kubectl cluster-info", "$ kubectl get pods"), ), - RunE: func(_ *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() if err := cache.Init(); err != nil { @@ -68,63 +81,55 @@ func NewCmd(p *print.Printer) *cobra.Command { "See `stackit ske kubeconfig login --help` for detailed usage instructions.") } - clusterConfig, err := parseClusterConfig() + idpMode := flags.FlagToBoolValue(params.Printer, cmd, idpFlag) + clusterConfig, err := parseClusterConfig(params.Printer, cmd, idpMode) if err != nil { return fmt.Errorf("parseClusterConfig: %w", err) } + if idpMode { + accessToken, err := getAccessToken(params) + if err != nil { + return err + } + idpClient := &http.Client{} + token, err := retrieveTokenFromIDP(ctx, idpClient, accessToken, clusterConfig) + if err != nil { + return err + } + return outputTokenKubeconfig(params.Printer, clusterConfig.cacheKey, token) + } + // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - - cachedKubeconfig := getCachedKubeConfig(clusterConfig.cacheKey) - - if cachedKubeconfig == nil { - return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil) - } - - certPem, _ := pem.Decode(cachedKubeconfig.CertData) - if certPem == nil { - _ = cache.DeleteObject(clusterConfig.cacheKey) - return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil) - } - - certificate, err := x509.ParseCertificate(certPem.Bytes) + kubeconfig, err := retrieveLoginKubeconfig(ctx, apiClient, clusterConfig) if err != nil { - _ = cache.DeleteObject(clusterConfig.cacheKey) - return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil) - } - - // cert is expired, request new - if time.Now().After(certificate.NotAfter.UTC()) { - _ = cache.DeleteObject(clusterConfig.cacheKey) - return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil) - } - // cert expires within the next 15min, refresh (try to get a new, use cache on failure) - if time.Now().Add(refreshBeforeDuration).After(certificate.NotAfter.UTC()) { - return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, true, cachedKubeconfig) - } - - // cert not expired, nor will it expire in the next 15min; therefore, use the cached kubeconfig - if err := output(p, clusterConfig.cacheKey, cachedKubeconfig); err != nil { return err } - return nil + return outputLoginKubeconfig(params.Printer, clusterConfig.cacheKey, kubeconfig) }, } + configureFlags(cmd) return cmd } +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(idpFlag, false, "Use the STACKIT IdP for authentication to the cluster.") +} + type clusterConfig struct { - STACKITProjectID string `json:"stackitProjectId"` + STACKITProjectID string `json:"stackitProjectID"` ClusterName string `json:"clusterName"` + Region string `json:"region"` + OrganizationID string `json:"organizationID"` cacheKey string } -func parseClusterConfig() (*clusterConfig, error) { +func parseClusterConfig(p *print.Printer, cmd *cobra.Command, idpMode bool) (*clusterConfig, error) { obj, _, err := exec.LoadExecCredentialFromEnv() if err != nil { return nil, fmt.Errorf("LoadExecCredentialFromEnv: %w", err) @@ -146,15 +151,52 @@ func parseClusterConfig() (*clusterConfig, error) { if execCredential == nil || execCredential.Spec.Cluster == nil { return nil, fmt.Errorf("ExecCredential contains not all needed fields") } - config := &clusterConfig{} - err = json.Unmarshal(execCredential.Spec.Cluster.Config.Raw, config) + clusterConfig := &clusterConfig{} + err = json.Unmarshal(execCredential.Spec.Cluster.Config.Raw, clusterConfig) if err != nil { return nil, fmt.Errorf("unmarshal: %w", err) } - config.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(execCredential.Spec.Cluster.Server))) + authEmail, err := auth.GetAuthEmail() + if err != nil { + return nil, fmt.Errorf("error getting auth email: %w", err) + } + idpSuffix := "" + if idpMode { + idpSuffix = "\x00idp" + } + clusterConfig.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(execCredential.Spec.Cluster.Server+"\x00"+authEmail+idpSuffix))) - return config, nil + // NOTE: Fallback if region is not set in the kubeconfig (this was the case in the past) + if clusterConfig.Region == "" { + clusterConfig.Region = globalflags.Parse(p, cmd).Region + } + + return clusterConfig, nil +} + +func retrieveLoginKubeconfig(ctx context.Context, apiClient *ske.APIClient, clusterConfig *clusterConfig) (*rest.Config, error) { + cachedKubeconfig := getCachedKubeConfig(clusterConfig.cacheKey) + if cachedKubeconfig == nil { + return requestNewLoginKubeconfig(ctx, apiClient, clusterConfig) + } + + isValid, notAfter := checkKubeconfigExpiry(cachedKubeconfig.CertData) + if !isValid { + // cert is expired or invalid, request new + _ = cache.DeleteObject(clusterConfig.cacheKey) + return requestNewLoginKubeconfig(ctx, apiClient, clusterConfig) + } else if time.Now().Add(refreshBeforeDuration).After(notAfter.UTC()) { + // cert expires within the next 15min -> refresh + kubeconfig, err := requestNewLoginKubeconfig(ctx, apiClient, clusterConfig) + // try to get a new one but use cache on failure + if err != nil { + return cachedKubeconfig, nil + } + return kubeconfig, nil + } + // cert not expired, nor will it expire in the next 15min; therefore, use the cached kubeconfig + return cachedKubeconfig, nil } func getCachedKubeConfig(key string) *rest.Config { @@ -171,63 +213,64 @@ func getCachedKubeConfig(key string) *rest.Config { return restConfig } -func GetAndOutputKubeconfig(ctx context.Context, p *print.Printer, apiClient *ske.APIClient, clusterConfig *clusterConfig, fallbackToCache bool, cachedKubeconfig *rest.Config) error { - req := buildRequest(ctx, apiClient, clusterConfig) - kubeconfigResponse, err := req.Execute() +func checkKubeconfigExpiry(certData []byte) (bool, time.Time) { + certPem, _ := pem.Decode(certData) + if certPem == nil { + return false, time.Time{} + } + + certificate, err := x509.ParseCertificate(certPem.Bytes) if err != nil { - if fallbackToCache { - return output(p, clusterConfig.cacheKey, cachedKubeconfig) - } - return fmt.Errorf("request kubeconfig: %w", err) + return false, time.Time{} + } + + // cert is expired + if time.Now().After(certificate.NotAfter.UTC()) { + return false, time.Time{} } + return true, certificate.NotAfter.UTC() +} +func requestNewLoginKubeconfig(ctx context.Context, apiClient *ske.APIClient, clusterConfig *clusterConfig) (*rest.Config, error) { + req := buildLoginKubeconfigRequest(ctx, apiClient, clusterConfig) + kubeconfigResponse, err := req.Execute() + if err != nil { + return nil, fmt.Errorf("request kubeconfig: %w", err) + } kubeconfig, err := clientcmd.RESTConfigFromKubeConfig([]byte(*kubeconfigResponse.Kubeconfig)) if err != nil { - if fallbackToCache { - return output(p, clusterConfig.cacheKey, cachedKubeconfig) - } - return fmt.Errorf("parse kubeconfig: %w", err) + return nil, fmt.Errorf("parse kubeconfig: %w", err) } if err = cache.PutObject(clusterConfig.cacheKey, []byte(*kubeconfigResponse.Kubeconfig)); err != nil { - if fallbackToCache { - return output(p, clusterConfig.cacheKey, cachedKubeconfig) - } - return fmt.Errorf("cache kubeconfig: %w", err) + return nil, fmt.Errorf("cache kubeconfig: %w", err) } - return output(p, clusterConfig.cacheKey, kubeconfig) + return kubeconfig, nil } -func buildRequest(ctx context.Context, apiClient *ske.APIClient, clusterConfig *clusterConfig) ske.ApiCreateKubeconfigRequest { - req := apiClient.CreateKubeconfig(ctx, clusterConfig.STACKITProjectID, clusterConfig.ClusterName) +func buildLoginKubeconfigRequest(ctx context.Context, apiClient *ske.APIClient, clusterConfig *clusterConfig) ske.ApiCreateKubeconfigRequest { + req := apiClient.DefaultAPI.CreateKubeconfig(ctx, clusterConfig.STACKITProjectID, clusterConfig.Region, clusterConfig.ClusterName) expirationSeconds := strconv.Itoa(expirationSeconds) return req.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{ExpirationSeconds: &expirationSeconds}) } -func output(p *print.Printer, cacheKey string, kubeconfig *rest.Config) error { - if kubeconfig == nil { - _ = cache.DeleteObject(cacheKey) - return errors.New("kubeconfig is nil") - } - - outputExecCredential, err := parseKubeConfigToExecCredential(kubeconfig) +func outputLoginKubeconfig(p *print.Printer, cacheKey string, kubeconfig *rest.Config) error { + output, err := parseLoginKubeConfigToExecCredential(kubeconfig) if err != nil { _ = cache.DeleteObject(cacheKey) return fmt.Errorf("convert to ExecCredential: %w", err) } - output, err := json.Marshal(outputExecCredential) - if err != nil { - _ = cache.DeleteObject(cacheKey) - return fmt.Errorf("marshal ExecCredential: %w", err) - } - p.Outputf("%s", string(output)) return nil } -func parseKubeConfigToExecCredential(kubeconfig *rest.Config) (*clientauthenticationv1.ExecCredential, error) { +func parseLoginKubeConfigToExecCredential(kubeconfig *rest.Config) ([]byte, error) { + if kubeconfig == nil { + return nil, errors.New("kubeconfig is nil") + } + certPem, _ := pem.Decode(kubeconfig.CertData) if certPem == nil { return nil, fmt.Errorf("decoded pem is nil") @@ -244,10 +287,127 @@ func parseKubeConfigToExecCredential(kubeconfig *rest.Config) (*clientauthentica Kind: "ExecCredential", }, Status: &clientauthenticationv1.ExecCredentialStatus{ - ExpirationTimestamp: &v1.Time{Time: certificate.NotAfter.Add(-time.Minute * 15)}, + ExpirationTimestamp: &v1.Time{Time: certificate.NotAfter.Add(-refreshBeforeDuration)}, ClientCertificateData: string(kubeconfig.CertData), ClientKeyData: string(kubeconfig.KeyData), }, } - return &outputExecCredential, nil + + output, err := json.Marshal(outputExecCredential) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + return output, nil +} + +func getAccessToken(params *types.CmdParams) (string, error) { + userSessionExpired, err := auth.UserSessionExpired() + if err != nil { + return "", err + } + if userSessionExpired { + return "", &cliErr.SessionExpiredError{} + } + + accessToken, err := auth.GetValidAccessToken(params.Printer) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get valid access token: %v", err) + return "", &cliErr.SessionExpiredError{} + } + + err = auth.EnsureIDPTokenEndpoint(params.Printer) + if err != nil { + return "", err + } + + return accessToken, nil +} + +func retrieveTokenFromIDP(ctx context.Context, idpClient *http.Client, accessToken string, clusterConfig *clusterConfig) (string, error) { + resource := resourceForCluster(clusterConfig) + + cachedToken := getCachedToken(clusterConfig.cacheKey) + if cachedToken == "" { + return exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey) + } + + expiry, err := auth.TokenExpirationTime(cachedToken) + if err != nil { + // token is expired or invalid, request new + _ = cache.DeleteObject(clusterConfig.cacheKey) + return exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey) + } else if time.Now().Add(refreshTokenBeforeDuration).After(expiry) { + // token expires soon -> refresh + token, err := exchangeAndCacheToken(ctx, idpClient, accessToken, resource, clusterConfig.cacheKey) + // try to get a new one but use cache on failure + if err != nil { + return cachedToken, nil + } + return token, nil + } + // cached token is valid and won't expire soon + return cachedToken, nil +} + +func resourceForCluster(config *clusterConfig) string { + return fmt.Sprintf( + "resource://organizations/%s/projects/%s/regions/%s/ske/%s", + config.OrganizationID, + config.STACKITProjectID, + config.Region, + config.ClusterName, + ) +} + +func getCachedToken(key string) string { + token, err := cache.GetObject(key) + if err != nil { + return "" + } + return string(token) +} + +func exchangeAndCacheToken(ctx context.Context, idpClient *http.Client, accessToken, resource, cacheKey string) (string, error) { + clusterToken, err := auth.ExchangeToken(ctx, idpClient, accessToken, resource) + if err != nil { + return "", err + } + if err = cache.PutObject(cacheKey, []byte(clusterToken)); err != nil { + return "", fmt.Errorf("cache token: %w", err) + } + return clusterToken, err +} + +func outputTokenKubeconfig(p *print.Printer, cacheKey, token string) error { + output, err := parseTokenToExecCredential(token) + if err != nil { + _ = cache.DeleteObject(cacheKey) + return fmt.Errorf("convert to ExecCredential: %w", err) + } + + p.Outputf("%s", string(output)) + return nil +} + +func parseTokenToExecCredential(clusterToken string) ([]byte, error) { + expiry, err := auth.TokenExpirationTime(clusterToken) + if err != nil { + return nil, fmt.Errorf("parse auth token for cluster: %w", err) + } + + outputExecCredential := clientauthenticationv1.ExecCredential{ + TypeMeta: v1.TypeMeta{ + APIVersion: clientauthenticationv1.SchemeGroupVersion.String(), + Kind: "ExecCredential", + }, + Status: &clientauthenticationv1.ExecCredentialStatus{ + ExpirationTimestamp: &v1.Time{Time: expiry.Add(-refreshTokenBeforeDuration)}, + Token: clusterToken, + }, + } + output, err := json.Marshal(&outputExecCredential) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + return output, nil } diff --git a/internal/cmd/ske/kubeconfig/login/login_test.go b/internal/cmd/ske/kubeconfig/login/login_test.go index c6b94c9a9..683171b95 100644 --- a/internal/cmd/ske/kubeconfig/login/login_test.go +++ b/internal/cmd/ske/kubeconfig/login/login_test.go @@ -2,31 +2,39 @@ package login import ( "context" + "encoding/json" "testing" "time" + "github.com/golang-jwt/jwt/v5" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/ske" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" "k8s.io/client-go/rest" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} var testProjectId = uuid.NewString() var testClusterName = "cluster" +var testOrganization = uuid.NewString() + +const testRegion = "eu01" func fixtureClusterConfig(mods ...func(clusterConfig *clusterConfig)) *clusterConfig { clusterConfig := &clusterConfig{ STACKITProjectID: testProjectId, ClusterName: testClusterName, cacheKey: "", + Region: testRegion, + OrganizationID: testOrganization, } for _, mod := range mods { mod(clusterConfig) @@ -34,8 +42,8 @@ func fixtureClusterConfig(mods ...func(clusterConfig *clusterConfig)) *clusterCo return clusterConfig } -func fixtureRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) ske.ApiCreateKubeconfigRequest { - request := testClient.CreateKubeconfig(testCtx, testProjectId, testClusterName) +func fixtureLoginRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) ske.ApiCreateKubeconfigRequest { + request := testClient.DefaultAPI.CreateKubeconfig(testCtx, testProjectId, testRegion, testClusterName) request = request.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{}) for _, mod := range mods { mod(&request) @@ -52,18 +60,19 @@ func TestBuildRequest(t *testing.T) { { description: "expiration time", clusterConfig: fixtureClusterConfig(), - expectedRequest: fixtureRequest().CreateKubeconfigPayload(ske.CreateKubeconfigPayload{ + expectedRequest: fixtureLoginRequest().CreateKubeconfigPayload(ske.CreateKubeconfigPayload{ ExpirationSeconds: utils.Ptr("1800")}), }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request := buildRequest(testCtx, testClient, tt.clusterConfig) + request := buildLoginKubeconfigRequest(testCtx, testClient, tt.clusterConfig) diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -124,17 +133,78 @@ zbRjZmli7cnenEnfnNoFIGbgkbjGXRUCIC5zFtWXFK7kA+B2vDxD0DlLcQodNwi4 for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - execCredential, err := parseKubeConfigToExecCredential(tt.kubeconfig) + execCredential, err := parseLoginKubeConfigToExecCredential(tt.kubeconfig) + if err != nil { + t.Fatalf("func returned error: %s", err) + } + if execCredential == nil { + t.Fatal("execCredential is nil") + } + expected, _ := json.Marshal(tt.expectedExecCredentialRequest) + diff := cmp.Diff(execCredential, expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestParseTokenToExecCredential(t *testing.T) { + expirationTime := time.Now().Add(30 * time.Minute) + expectedTime := expirationTime.Add(-5 * time.Minute) + token, err := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + }).SigningString() + if err != nil { + t.Fatalf("token generation failed: %v", err) + } + token += ".signatureAAA" + + tests := []struct { + description string + token string + expectedExecCredentialRequest *clientauthenticationv1.ExecCredential + }{ + { + description: "expiration time", + token: token, + expectedExecCredentialRequest: &clientauthenticationv1.ExecCredential{ + TypeMeta: v1.TypeMeta{ + APIVersion: clientauthenticationv1.SchemeGroupVersion.String(), + Kind: "ExecCredential", + }, + Status: &clientauthenticationv1.ExecCredentialStatus{ + ExpirationTimestamp: &v1.Time{Time: expectedTime}, + Token: token, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + execCredential, err := parseTokenToExecCredential(tt.token) if err != nil { t.Fatalf("func returned error: %s", err) } if execCredential == nil { t.Fatal("execCredential is nil") } - diff := cmp.Diff(execCredential, tt.expectedExecCredentialRequest) + expected, _ := json.Marshal(tt.expectedExecCredentialRequest) + diff := cmp.Diff(execCredential, expected) if diff != "" { t.Fatalf("Data does not match: %s", diff) } }) } } + +func TestResourceForCluster(t *testing.T) { + cc := fixtureClusterConfig() + resource := resourceForCluster(cc) + // somewhat redundant, but the resource string must not change unexpectedly + expectedResource := "resource://organizations/" + testOrganization + "/projects/" + testProjectId + "/regions/" + testRegion + "/ske/" + testClusterName + if resource != expectedResource { + t.Fatalf("unexpected resource, got %v expected %v", resource, expectedResource) + } +} diff --git a/internal/cmd/ske/options/availability_zones/availability_zones.go b/internal/cmd/ske/options/availability_zones/availability_zones.go new file mode 100644 index 000000000..272da1eeb --- /dev/null +++ b/internal/cmd/ske/options/availability_zones/availability_zones.go @@ -0,0 +1,104 @@ +package availability_zones + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +type inputModel struct { + globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "availability-zones", + Short: "Lists SKE provider options for availability-zones", + Long: "Lists STACKIT Kubernetes Engine (SKE) provider options for availability-zones.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List SKE options for availability-zones`, + "$ stackit ske options availability-zones"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, apiClient, model) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get SKE provider options: %w", err) + } + + return outputResult(params.Printer, model, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: utils.PtrValue(globalFlags), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, apiClient *ske.APIClient, model *inputModel) ske.ApiListProviderOptionsRequest { + req := apiClient.DefaultAPI.ListProviderOptions(ctx, model.Region) + return req +} + +func outputResult(p *print.Printer, model *inputModel, options *ske.ProviderOptions) error { + if options == nil { + return fmt.Errorf("options is nil") + } + + options.KubernetesVersions = nil + options.MachineImages = nil + options.MachineTypes = nil + options.VolumeTypes = nil + + return p.OutputResult(model.OutputFormat, options, func() error { + zones := options.AvailabilityZones + + table := tables.NewTable() + table.SetHeader("ZONE") + for i := range zones { + z := zones[i] + table.AddRow(utils.PtrValue(z.Name)) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/ske/options/availability_zones/availability_zones_test.go b/internal/cmd/ske/options/availability_zones/availability_zones_test.go new file mode 100644 index 000000000..03229b4cf --- /dev/null +++ b/internal/cmd/ske/options/availability_zones/availability_zones_test.go @@ -0,0 +1,204 @@ +package availability_zones + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []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: fixtureInputModel(func(model *inputModel) { + model.Region = "" + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + inputModel *inputModel + expectedRequest ske.ApiListProviderOptionsRequest + }{ + { + description: "base", + inputModel: fixtureInputModel(), + expectedRequest: testClient.DefaultAPI.ListProviderOptions(testCtx, testRegion), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient, tt.inputModel) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + options *ske.ProviderOptions + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "missing options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + }, + wantErr: true, + }, + { + name: "empty input model", + args: args{ + model: &inputModel{}, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "set model and options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "empty values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + AvailabilityZones: []ske.AvailabilityZone{}, + }, + }, + wantErr: false, + }, + { + name: "empty value in values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + AvailabilityZones: []ske.AvailabilityZone{{}}, + }, + }, + wantErr: false, + }, + { + name: "valid values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + AvailabilityZones: []ske.AvailabilityZone{ + { + Name: utils.Ptr("zone1"), + }, + { + Name: utils.Ptr("zone2"), + }, + }, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.options); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ske/options/kubernetes_versions/kubernetes_versions.go b/internal/cmd/ske/options/kubernetes_versions/kubernetes_versions.go new file mode 100644 index 000000000..65b21b4b8 --- /dev/null +++ b/internal/cmd/ske/options/kubernetes_versions/kubernetes_versions.go @@ -0,0 +1,136 @@ +package kubernetes_versions + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +const ( + supportedFlag = "supported" +) + +type inputModel struct { + globalflags.GlobalFlagModel + Supported bool +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "kubernetes-versions", + Short: "Lists SKE provider options for kubernetes-versions", + Long: "Lists STACKIT Kubernetes Engine (SKE) provider options for kubernetes-versions.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List SKE options for kubernetes-versions`, + "$ stackit ske options kubernetes-versions"), + examples.NewExample( + `List SKE options for supported kubernetes-versions`, + "$ stackit ske options kubernetes-versions --supported"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, apiClient, model) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get SKE provider options: %w", err) + } + + return outputResult(params.Printer, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(supportedFlag, false, "List supported versions only") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: utils.PtrValue(globalFlags), + Supported: flags.FlagToBoolValue(p, cmd, supportedFlag), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, apiClient *ske.APIClient, model *inputModel) ske.ApiListProviderOptionsRequest { + req := apiClient.DefaultAPI.ListProviderOptions(ctx, model.Region) + if model.Supported { + req = req.VersionState("SUPPORTED") + } + return req +} + +func outputResult(p *print.Printer, model *inputModel, options *ske.ProviderOptions) error { + if options == nil { + return fmt.Errorf("options is nil") + } + + options.AvailabilityZones = nil + options.MachineImages = nil + options.MachineTypes = nil + options.VolumeTypes = nil + + return p.OutputResult(model.OutputFormat, options, func() error { + versions := options.KubernetesVersions + + table := tables.NewTable() + table.SetHeader("VERSION", "STATE", "EXPIRATION DATE", "FEATURE GATES") + for i := range versions { + v := versions[i] + featureGate, err := json.Marshal(utils.PtrValue(v.FeatureGates)) + if err != nil { + return fmt.Errorf("marshal featureGates of Kubernetes version %q: %w", utils.PtrValue(v.Version), err) + } + expirationDate := "" + if v.ExpirationDate != nil { + expirationDate = v.ExpirationDate.Format(time.RFC3339) + } + table.AddRow( + utils.PtrString(v.Version), + utils.PtrString(v.State), + expirationDate, + string(featureGate)) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/ske/options/kubernetes_versions/kubernetes_versions_test.go b/internal/cmd/ske/options/kubernetes_versions/kubernetes_versions_test.go new file mode 100644 index 000000000..54f246933 --- /dev/null +++ b/internal/cmd/ske/options/kubernetes_versions/kubernetes_versions_test.go @@ -0,0 +1,232 @@ +package kubernetes_versions + +import ( + "context" + "testing" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + supportedFlag: "false", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Supported: false, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []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: fixtureInputModel(func(model *inputModel) { + model.Region = "" + }), + }, + { + description: "supported only", + flagValues: map[string]string{ + supportedFlag: "true", + }, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Supported = true + model.Region = "" + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + inputModel *inputModel + expectedRequest ske.ApiListProviderOptionsRequest + }{ + { + description: "base", + inputModel: fixtureInputModel(), + expectedRequest: testClient.DefaultAPI.ListProviderOptions(testCtx, testRegion), + }, + { + description: "base", + inputModel: fixtureInputModel(func(model *inputModel) { + model.Supported = true + }), + expectedRequest: testClient.DefaultAPI.ListProviderOptions(testCtx, testRegion).VersionState("SUPPORTED"), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient, tt.inputModel) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + options *ske.ProviderOptions + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "missing options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + }, + wantErr: true, + }, + { + name: "empty input model", + args: args{ + model: &inputModel{}, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "set model and options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "empty values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + KubernetesVersions: []ske.KubernetesVersion{}, + }, + }, + wantErr: false, + }, + { + name: "empty value in values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + KubernetesVersions: []ske.KubernetesVersion{{}}, + }, + }, + wantErr: false, + }, + { + name: "valid values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + KubernetesVersions: []ske.KubernetesVersion{ + { + FeatureGates: &map[string]string{ + "featureGate1": "foo", + "featureGate2": "bar", + }, + State: utils.Ptr("supported"), + Version: utils.Ptr("0.00.0"), + }, + { + ExpirationDate: utils.Ptr(time.Now()), + State: utils.Ptr("deprecated"), + Version: utils.Ptr("0.00.0"), + }, + }, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.options); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ske/options/machine_images/machine_images.go b/internal/cmd/ske/options/machine_images/machine_images.go new file mode 100644 index 000000000..94110cf6f --- /dev/null +++ b/internal/cmd/ske/options/machine_images/machine_images.go @@ -0,0 +1,128 @@ +package machine_images + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "machine-images", + Short: "Lists SKE provider options for machine-images", + Long: "Lists STACKIT Kubernetes Engine (SKE) provider options for machine-images.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List SKE options for machine-images`, + "$ stackit ske options machine-images"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, apiClient, model) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get SKE provider options: %w", err) + } + + return outputResult(params.Printer, model, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: utils.PtrValue(globalFlags), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, apiClient *ske.APIClient, model *inputModel) ske.ApiListProviderOptionsRequest { + req := apiClient.DefaultAPI.ListProviderOptions(ctx, model.Region) + return req +} + +func outputResult(p *print.Printer, model *inputModel, options *ske.ProviderOptions) error { + if options == nil { + return fmt.Errorf("options is nil") + } + + options.AvailabilityZones = nil + options.KubernetesVersions = nil + options.MachineTypes = nil + options.VolumeTypes = nil + + return p.OutputResult(model.OutputFormat, options, func() error { + images := options.MachineImages + + table := tables.NewTable() + table.SetHeader("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, utils.PtrString(cri.Name)) + } + criNamesString := strings.Join(criNames, ", ") + + expirationDate := "-" + if version.ExpirationDate != nil { + expirationDate = version.ExpirationDate.Format(time.RFC3339) + } + table.AddRow( + utils.PtrString(image.Name), + utils.PtrString(version.Version), + utils.PtrString(version.State), + expirationDate, + criNamesString, + ) + } + } + table.EnableAutoMergeOnColumns(1) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/ske/options/machine_images/machine_images_test.go b/internal/cmd/ske/options/machine_images/machine_images_test.go new file mode 100644 index 000000000..ad4658757 --- /dev/null +++ b/internal/cmd/ske/options/machine_images/machine_images_test.go @@ -0,0 +1,217 @@ +package machine_images + +import ( + "context" + "testing" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []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: fixtureInputModel(func(model *inputModel) { + model.Region = "" + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + inputModel *inputModel + expectedRequest ske.ApiListProviderOptionsRequest + }{ + { + description: "base", + inputModel: fixtureInputModel(), + expectedRequest: testClient.DefaultAPI.ListProviderOptions(testCtx, testRegion), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient, tt.inputModel) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + options *ske.ProviderOptions + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "missing options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + }, + wantErr: true, + }, + { + name: "empty input model", + args: args{ + model: &inputModel{}, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "set model and options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "empty values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + MachineImages: []ske.MachineImage{}, + }, + }, + wantErr: false, + }, + { + name: "empty value in values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + MachineImages: []ske.MachineImage{{}}, + }, + }, + wantErr: false, + }, + { + name: "valid values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + MachineImages: []ske.MachineImage{ + { + Name: utils.Ptr("image1"), + Versions: []ske.MachineImageVersion{ + { + Cri: []ske.CRI{ + { + Name: utils.Ptr("containerd"), + }, + }, + ExpirationDate: utils.Ptr(time.Now()), + State: utils.Ptr("supported"), + Version: utils.Ptr("0.00.0"), + }, + }, + }, + { + Name: utils.Ptr("zone2"), + }, + }, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.options); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ske/options/machine_types/machine_types.go b/internal/cmd/ske/options/machine_types/machine_types.go new file mode 100644 index 000000000..7fce1c279 --- /dev/null +++ b/internal/cmd/ske/options/machine_types/machine_types.go @@ -0,0 +1,108 @@ +package machine_types + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type inputModel struct { + globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "machine-types", + Short: "Lists SKE provider options for machine-types", + Long: "Lists STACKIT Kubernetes Engine (SKE) provider options for machine-types.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List SKE options for machine-types`, + "$ stackit ske options machine-types"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, apiClient, model) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get SKE provider options: %w", err) + } + + return outputResult(params.Printer, model, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: utils.PtrValue(globalFlags), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, apiClient *ske.APIClient, model *inputModel) ske.ApiListProviderOptionsRequest { + req := apiClient.DefaultAPI.ListProviderOptions(ctx, model.Region) + return req +} + +func outputResult(p *print.Printer, model *inputModel, options *ske.ProviderOptions) error { + if options == nil { + return fmt.Errorf("options is nil") + } + + options.AvailabilityZones = nil + options.KubernetesVersions = nil + options.MachineImages = nil + options.VolumeTypes = nil + + return p.OutputResult(model.OutputFormat, options, func() error { + machineTypes := options.MachineTypes + + table := tables.NewTable() + table.SetHeader("TYPE", "CPU", "MEMORY") + for i := range machineTypes { + t := machineTypes[i] + table.AddRow( + utils.PtrString(t.Name), + utils.PtrString(t.Cpu), + utils.PtrString(t.Memory), + ) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/ske/options/machine_types/machine_types_test.go b/internal/cmd/ske/options/machine_types/machine_types_test.go new file mode 100644 index 000000000..ef405139f --- /dev/null +++ b/internal/cmd/ske/options/machine_types/machine_types_test.go @@ -0,0 +1,212 @@ +package machine_types + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []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: fixtureInputModel(func(model *inputModel) { + model.Region = "" + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + inputModel *inputModel + expectedRequest ske.ApiListProviderOptionsRequest + }{ + { + description: "base", + inputModel: fixtureInputModel(), + expectedRequest: testClient.DefaultAPI.ListProviderOptions(testCtx, testRegion), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient, tt.inputModel) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + options *ske.ProviderOptions + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "missing options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + }, + wantErr: true, + }, + { + name: "empty input model", + args: args{ + model: &inputModel{}, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "set model and options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "empty values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + MachineTypes: []ske.MachineType{}, + }, + }, + wantErr: false, + }, + { + name: "empty value in values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + MachineTypes: []ske.MachineType{{}}, + }, + }, + wantErr: false, + }, + { + name: "valid values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + MachineTypes: []ske.MachineType{ + { + Architecture: utils.Ptr("amd64"), + Cpu: utils.Ptr(int32(2)), + Gpu: utils.Ptr(int32(0)), + Memory: utils.Ptr(int32(16)), + Name: utils.Ptr("type1"), + }, + { + Architecture: utils.Ptr("amd64"), + Cpu: utils.Ptr(int32(2)), + Gpu: utils.Ptr(int32(0)), + Memory: utils.Ptr(int32(16)), + Name: utils.Ptr("type2"), + }, + }, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.options); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ske/options/options.go b/internal/cmd/ske/options/options.go index 419721d29..8eb90ae9c 100644 --- a/internal/cmd/ske/options/options.go +++ b/internal/cmd/ske/options/options.go @@ -7,17 +7,23 @@ import ( "strings" "time" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/options/availability_zones" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/options/kubernetes_versions" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/options/machine_images" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/options/machine_types" + "github.com/stackitcloud/stackit-cli/internal/cmd/ske/options/volume_types" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/flags" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/ske" ) const ( @@ -29,7 +35,7 @@ const ( ) type inputModel struct { - *globalflags.GlobalFlagModel + globalflags.GlobalFlagModel AvailabilityZones bool KubernetesVersions bool MachineImages bool @@ -37,62 +43,69 @@ type inputModel struct { VolumeTypes bool } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "options", Short: "Lists SKE provider options", - Long: fmt.Sprintf("%s\n%s", + Long: fmt.Sprintf("%s\n%s\n%s", + "Command \"options\" is deprecated, use the subcommands instead.", "Lists 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.", ), 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, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { + params.Printer.Info("Command \"options\" is deprecated, use the subcommands instead.\n") + ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } // Call API - req := buildRequest(ctx, apiClient) + req := buildRequest(ctx, apiClient, model) resp, err := req.Execute() if err != nil { return fmt.Errorf("get SKE provider options: %w", err) } - return outputResult(p, model, resp) + return outputResult(params.Printer, model, resp) }, } configureFlags(cmd) + addSubcommands(cmd, params) return cmd } +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(availability_zones.NewCmd(params)) + cmd.AddCommand(kubernetes_versions.NewCmd(params)) + cmd.AddCommand(machine_images.NewCmd(params)) + cmd.AddCommand(machine_types.NewCmd(params)) + cmd.AddCommand(volume_types.NewCmd(params)) +} + 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") + + cobra.CheckErr(cmd.Flags().MarkDeprecated(availabilityZonesFlag, "This flag is deprecated and will be removed on 2026-09-26. Use the availability-zone subcommand instead.")) + cobra.CheckErr(cmd.Flags().MarkDeprecated(kubernetesVersionsFlag, "This flag is deprecated and will be removed on 2026-09-26. Use the kubernetes-versions subcommand instead.")) + cobra.CheckErr(cmd.Flags().MarkDeprecated(machineImagesFlag, "This flag is deprecated and will be removed on 2026-09-26. Use the machine-images subcommand instead.")) + cobra.CheckErr(cmd.Flags().MarkDeprecated(machineTypesFlag, "This flag is deprecated and will be removed on 2026-09-26. Use the machine-types subcommand instead.")) + cobra.CheckErr(cmd.Flags().MarkDeprecated(volumeTypesFlag, "This flag is deprecated and will be removed on 2026-09-26. Use the volume-types subcommand instead.")) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) availabilityZones := flags.FlagToBoolValue(p, cmd, availabilityZonesFlag) kubernetesVersions := flags.FlagToBoolValue(p, cmd, kubernetesVersionsFlag) @@ -110,7 +123,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { } model := inputModel{ - GlobalFlagModel: globalFlags, + GlobalFlagModel: utils.PtrValue(globalFlags), AvailabilityZones: availabilityZones, KubernetesVersions: kubernetesVersions, MachineImages: machineImages, @@ -118,25 +131,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { VolumeTypes: volumeTypes, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } -func buildRequest(ctx context.Context, apiClient *ske.APIClient) ske.ApiListProviderOptionsRequest { - req := apiClient.ListProviderOptions(ctx) +func buildRequest(ctx context.Context, apiClient *ske.APIClient, model *inputModel) ske.ApiListProviderOptionsRequest { + req := apiClient.DefaultAPI.ListProviderOptions(ctx, model.Region) return req } func outputResult(p *print.Printer, model *inputModel, options *ske.ProviderOptions) error { - if model == nil || model.GlobalFlagModel == nil { + if model == nil { return fmt.Errorf("model is nil") } else if options == nil { return fmt.Errorf("options is nil") @@ -163,25 +168,9 @@ func outputResult(p *print.Printer, model *inputModel, options *ske.ProviderOpti options.VolumeTypes = nil } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(options, "", " ") - if err != nil { - return fmt.Errorf("marshal SKE options: %w", err) - } - p.Outputln(string(details)) - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(options, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal SKE options: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(model.OutputFormat, options, func() error { return outputResultAsTable(p, options) - } + }) } func outputResultAsTable(p *print.Printer, options *ske.ProviderOptions) error { @@ -190,11 +179,11 @@ func outputResultAsTable(p *print.Printer, options *ske.ProviderOptions) error { } content := []tables.Table{} - if options.AvailabilityZones != nil && len(*options.AvailabilityZones) != 0 { + if len(options.AvailabilityZones) != 0 { content = append(content, buildAvailabilityZonesTable(options)) } - if options.KubernetesVersions != nil && len(*options.KubernetesVersions) != 0 { + if len(options.KubernetesVersions) != 0 { kubernetesVersionsTable, err := buildKubernetesVersionsTable(options) if err != nil { return fmt.Errorf("build Kubernetes versions table: %w", err) @@ -202,15 +191,15 @@ func outputResultAsTable(p *print.Printer, options *ske.ProviderOptions) error { content = append(content, kubernetesVersionsTable) } - if options.MachineImages != nil && len(*options.MachineImages) != 0 { + if len(options.MachineImages) != 0 { content = append(content, buildMachineImagesTable(options)) } - if options.MachineTypes != nil && len(*options.MachineTypes) != 0 { + if len(options.MachineTypes) != 0 { content = append(content, buildMachineTypesTable(options)) } - if options.VolumeTypes != nil && len(*options.VolumeTypes) != 0 { + if len(options.VolumeTypes) != 0 { content = append(content, buildVolumeTypesTable(options)) } @@ -223,7 +212,7 @@ func outputResultAsTable(p *print.Printer, options *ske.ProviderOptions) error { } func buildAvailabilityZonesTable(resp *ske.ProviderOptions) tables.Table { - zones := *resp.AvailabilityZones + zones := resp.AvailabilityZones table := tables.NewTable() table.SetTitle("Availability Zones") @@ -236,7 +225,7 @@ func buildAvailabilityZonesTable(resp *ske.ProviderOptions) tables.Table { } func buildKubernetesVersionsTable(resp *ske.ProviderOptions) (tables.Table, error) { - versions := *resp.KubernetesVersions + versions := resp.KubernetesVersions table := tables.NewTable() table.SetTitle("Kubernetes Versions") @@ -261,20 +250,20 @@ func buildKubernetesVersionsTable(resp *ske.ProviderOptions) (tables.Table, erro } func buildMachineImagesTable(resp *ske.ProviderOptions) tables.Table { - images := *resp.MachineImages + images := resp.MachineImages table := tables.NewTable() table.SetTitle("Machine Images") table.SetHeader("NAME", "VERSION", "STATE", "EXPIRATION DATE", "SUPPORTED CRI") for i := range images { image := images[i] - versions := *image.Versions + 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) + for i := range version.Cri { + cri := version.Cri[i] + criNames = append(criNames, string(*cri.Name)) } criNamesString := strings.Join(criNames, ", ") @@ -296,13 +285,13 @@ func buildMachineImagesTable(resp *ske.ProviderOptions) tables.Table { } func buildMachineTypesTable(resp *ske.ProviderOptions) tables.Table { - types := *resp.MachineTypes + machineTypes := resp.MachineTypes table := tables.NewTable() table.SetTitle("Machine Types") table.SetHeader("TYPE", "CPU", "MEMORY") - for i := range types { - t := types[i] + for i := range machineTypes { + t := machineTypes[i] table.AddRow( utils.PtrString(t.Name), utils.PtrString(t.Cpu), @@ -313,13 +302,13 @@ func buildMachineTypesTable(resp *ske.ProviderOptions) tables.Table { } func buildVolumeTypesTable(resp *ske.ProviderOptions) tables.Table { - types := *resp.VolumeTypes + volumeTypes := resp.VolumeTypes table := tables.NewTable() table.SetTitle("Volume Types") table.SetHeader("TYPE") - for i := range types { - z := types[i] + for i := range volumeTypes { + z := volumeTypes[i] table.AddRow(utils.PtrString(z.Name)) } return table diff --git a/internal/cmd/ske/options/options_test.go b/internal/cmd/ske/options/options_test.go index 6b1c1ae93..52ebe0a26 100644 --- a/internal/cmd/ske/options/options_test.go +++ b/internal/cmd/ske/options/options_test.go @@ -4,18 +4,23 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/stackitcloud/stackit-sdk-go/services/ske" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &ske.APIClient{} +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} + +const testRegion = "eu01" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ @@ -24,6 +29,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st machineImagesFlag: "false", machineTypesFlag: "false", volumeTypesFlag: "false", + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -33,7 +39,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st func fixtureInputModelAllFalse(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, + GlobalFlagModel: globalflags.GlobalFlagModel{Region: testRegion, Verbosity: globalflags.VerbosityDefault}, AvailabilityZones: false, KubernetesVersions: false, MachineImages: false, @@ -48,7 +54,7 @@ func fixtureInputModelAllFalse(mods ...func(model *inputModel)) *inputModel { func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{Verbosity: globalflags.VerbosityDefault}, + GlobalFlagModel: globalflags.GlobalFlagModel{Region: testRegion, Verbosity: globalflags.VerbosityDefault}, AvailabilityZones: true, KubernetesVersions: true, MachineImages: true, @@ -64,6 +70,7 @@ func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -75,10 +82,12 @@ func TestParseInput(t *testing.T) { expectedModel: fixtureInputModelAllTrue(), }, { - description: "no values", - flagValues: map[string]string{}, - isValid: true, - expectedModel: fixtureInputModelAllTrue(), + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: fixtureInputModelAllTrue(func(model *inputModel) { + model.Region = "" + }), }, { description: "some values 1", @@ -89,6 +98,7 @@ func TestParseInput(t *testing.T) { isValid: true, expectedModel: fixtureInputModelAllFalse(func(model *inputModel) { model.AvailabilityZones = true + model.Region = "" }), }, { @@ -102,6 +112,7 @@ func TestParseInput(t *testing.T) { expectedModel: fixtureInputModelAllFalse(func(model *inputModel) { model.KubernetesVersions = true model.MachineTypes = true + model.Region = "" }), }, { @@ -110,53 +121,16 @@ func TestParseInput(t *testing.T) { kubernetesVersionsFlag: "false", machineTypesFlag: "false", }, - isValid: true, - expectedModel: fixtureInputModelAllTrue(), + isValid: true, + expectedModel: fixtureInputModelAllTrue(func(model *inputModel) { + model.Region = "" + }), }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -168,17 +142,18 @@ func TestBuildRequest(t *testing.T) { }{ { description: "base", - expectedRequest: testClient.ListProviderOptions(testCtx), + expectedRequest: testClient.DefaultAPI.ListProviderOptions(testCtx, testRegion), }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request := buildRequest(testCtx, testClient) + request := buildRequest(testCtx, testClient, fixtureInputModelAllTrue()) diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), ) if diff != "" { t.Fatalf("Data does not match: %s", diff) @@ -213,24 +188,24 @@ func TestOutputResult(t *testing.T) { name: "missing options", args: args{ model: &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{}, + GlobalFlagModel: globalflags.GlobalFlagModel{}, }, }, wantErr: true, }, { - name: "missing global flags in model", + name: "empty input model", args: args{ model: &inputModel{}, options: &ske.ProviderOptions{}, }, - wantErr: true, + wantErr: false, }, { name: "set model and options", args: args{ model: &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{}, + GlobalFlagModel: globalflags.GlobalFlagModel{}, }, options: &ske.ProviderOptions{}, }, @@ -238,7 +213,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.options); (err != nil) != tt.wantErr { @@ -271,7 +246,7 @@ func TestOutputResultAsTable(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResultAsTable(p, tt.args.options); (err != nil) != tt.wantErr { diff --git a/internal/cmd/ske/options/volume_types/volume_types.go b/internal/cmd/ske/options/volume_types/volume_types.go new file mode 100644 index 000000000..1b4943ae4 --- /dev/null +++ b/internal/cmd/ske/options/volume_types/volume_types.go @@ -0,0 +1,104 @@ +package volume_types + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" +) + +type inputModel struct { + globalflags.GlobalFlagModel +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "volume-types", + Short: "Lists SKE provider options for volume-types", + Long: "Lists STACKIT Kubernetes Engine (SKE) provider options for volume-types.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List SKE options for volume-types`, + "$ stackit ske options volume-types"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, apiClient, model) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get SKE provider options: %w", err) + } + + return outputResult(params.Printer, model, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + + model := inputModel{ + GlobalFlagModel: utils.PtrValue(globalFlags), + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, apiClient *ske.APIClient, model *inputModel) ske.ApiListProviderOptionsRequest { + req := apiClient.DefaultAPI.ListProviderOptions(ctx, model.Region) + return req +} + +func outputResult(p *print.Printer, model *inputModel, options *ske.ProviderOptions) error { + if options == nil { + return fmt.Errorf("options is nil") + } + + options.AvailabilityZones = nil + options.KubernetesVersions = nil + options.MachineImages = nil + options.MachineTypes = nil + + return p.OutputResult(model.OutputFormat, options, func() error { + volumeTypes := options.VolumeTypes + + table := tables.NewTable() + table.SetHeader("TYPE") + for i := range volumeTypes { + z := volumeTypes[i] + table.AddRow(utils.PtrString(z.Name)) + } + + err := table.Display(p) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + return nil + }) +} diff --git a/internal/cmd/ske/options/volume_types/volume_types_test.go b/internal/cmd/ske/options/volume_types/volume_types_test.go new file mode 100644 index 000000000..ea926f3f0 --- /dev/null +++ b/internal/cmd/ske/options/volume_types/volume_types_test.go @@ -0,0 +1,204 @@ +package volume_types + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{DefaultAPI: &ske.DefaultAPIService{}} + +const testRegion = "eu01" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{ + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []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: fixtureInputModel(func(model *inputModel) { + model.Region = "" + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + inputModel *inputModel + expectedRequest ske.ApiListProviderOptionsRequest + }{ + { + description: "base", + inputModel: fixtureInputModel(), + expectedRequest: testClient.DefaultAPI.ListProviderOptions(testCtx, testRegion), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient, tt.inputModel) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + cmpopts.EquateComparable(testClient.DefaultAPI), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + model *inputModel + options *ske.ProviderOptions + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "missing options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + }, + wantErr: true, + }, + { + name: "empty input model", + args: args{ + model: &inputModel{}, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "set model and options", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{}, + }, + wantErr: false, + }, + { + name: "empty values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + VolumeTypes: []ske.VolumeType{}, + }, + }, + wantErr: false, + }, + { + name: "empty value in values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + VolumeTypes: []ske.VolumeType{{}}, + }, + }, + wantErr: false, + }, + { + name: "valid values", + args: args{ + model: &inputModel{ + GlobalFlagModel: globalflags.GlobalFlagModel{}, + }, + options: &ske.ProviderOptions{ + VolumeTypes: []ske.VolumeType{ + { + Name: utils.Ptr("type1"), + }, + { + Name: utils.Ptr("type2"), + }, + }, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.model, tt.args.options); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/ske/ske.go b/internal/cmd/ske/ske.go index 04438af65..3c052fa71 100644 --- a/internal/cmd/ske/ske.go +++ b/internal/cmd/ske/ske.go @@ -9,13 +9,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/ske/kubeconfig" "github.com/stackitcloud/stackit-cli/internal/cmd/ske/options" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "ske", Short: "Provides functionality for SKE", @@ -23,16 +23,16 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(enable.NewCmd(p)) - cmd.AddCommand(kubeconfig.NewCmd(p)) - cmd.AddCommand(disable.NewCmd(p)) - cmd.AddCommand(cluster.NewCmd(p)) - cmd.AddCommand(credentials.NewCmd(p)) - cmd.AddCommand(options.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(cluster.NewCmd(params)) + cmd.AddCommand(credentials.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(disable.NewCmd(params)) + cmd.AddCommand(enable.NewCmd(params)) + cmd.AddCommand(kubeconfig.NewCmd(params)) + cmd.AddCommand(options.NewCmd(params)) } diff --git a/internal/cmd/volume/backup/backup.go b/internal/cmd/volume/backup/backup.go new file mode 100644 index 000000000..271336ba2 --- /dev/null +++ b/internal/cmd/volume/backup/backup.go @@ -0,0 +1,36 @@ +package backup + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/restore" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "backup", + Short: "Provides functionality for volume backups", + Long: "Provides functionality for volume backups.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(restore.NewCmd(params)) +} diff --git a/internal/cmd/volume/backup/create/create.go b/internal/cmd/volume/backup/create/create.go new file mode 100644 index 000000000..c2211a3ad --- /dev/null +++ b/internal/cmd/volume/backup/create/create.go @@ -0,0 +1,203 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" +) + +const ( + sourceIdFlag = "source-id" + sourceTypeFlag = "source-type" + nameFlag = "name" + labelsFlag = "labels" +) + +var sourceTypeFlagOptions = []string{"volume", "snapshot"} + +type inputModel struct { + *globalflags.GlobalFlagModel + SourceID string + SourceType string + Name *string + Labels map[string]string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a backup from a specific source", + Long: "Creates a backup from a specific source (volume or snapshot).", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a backup from a volume`, + "$ stackit volume backup create --source-id xxx --source-type volume"), + examples.NewExample( + `Create a backup from a snapshot with a name`, + "$ stackit volume backup create --source-id xxx --source-type snapshot --name my-backup"), + examples.NewExample( + `Create a backup with labels`, + "$ stackit volume backup create --source-id xxx --source-type volume --labels key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Get source name for label (use ID if name not available) + sourceLabel := model.SourceID + + switch model.SourceType { + case "volume": + name, err := iaasutils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.SourceID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) + } else if name != "" { + sourceLabel = name + } + case "snapshot": + name, err := iaasutils.GetSnapshotName(ctx, apiClient, model.ProjectId, model.Region, model.SourceID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) + } else if name != "" { + sourceLabel = name + } + } + + prompt := fmt.Sprintf("Are you sure you want to create backup from %s? (This cannot be undone)", sourceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create volume backup: %w", err) + } + if resp == nil || resp.Id == nil { + return fmt.Errorf("create volume: empty response") + } + volumeId := *resp.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + resp, err = spinner.Run2(params.Printer, "Creating backup", func() (*iaas.Backup, error) { + return wait.CreateBackupWaitHandler(ctx, apiClient, model.ProjectId, model.Region, volumeId).WaitWithContext(ctx) + }) + if err != nil { + return fmt.Errorf("wait for backup creation: %w", err) + } + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, sourceLabel, projectLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(sourceIdFlag, "", "ID of the source from which a backup should be created") + cmd.Flags().Var(flags.EnumFlag(false, "", sourceTypeFlagOptions...), sourceTypeFlag, fmt.Sprintf("Source type of the backup, one of %q", sourceTypeFlagOptions)) + cmd.Flags().String(nameFlag, "", "Name of the backup") + cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") + + err := flags.MarkFlagsRequired(cmd, sourceIdFlag, sourceTypeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + sourceID := flags.FlagToStringValue(p, cmd, sourceIdFlag) + if sourceID == "" { + return nil, fmt.Errorf("source-id is required") + } + + sourceType := flags.FlagToStringValue(p, cmd, sourceTypeFlag) + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) + if labels == nil { + labels = &map[string]string{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SourceID: sourceID, + SourceType: sourceType, + Name: name, + Labels: *labels, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateBackupRequest { + req := apiClient.CreateBackup(ctx, model.ProjectId, model.Region) + + payload := iaas.CreateBackupPayload{ + Name: model.Name, + Labels: utils.ConvertStringMapToInterfaceMap(utils.Ptr(model.Labels)), + Source: &iaas.BackupSource{ + Id: &model.SourceID, + Type: &model.SourceType, + }, + } + + return req.CreateBackupPayload(payload) +} + +func outputResult(p *print.Printer, outputFormat string, async bool, sourceLabel, projectLabel string, resp *iaas.Backup) error { + if resp == nil { + return fmt.Errorf("create backup response is empty") + } + + return p.OutputResult(outputFormat, resp, func() error { + if async { + p.Outputf("Triggered backup of %s in %s. Backup ID: %s\n", sourceLabel, projectLabel, utils.PtrString(resp.Id)) + } else { + p.Outputf("Created backup of %s in %s. Backup ID: %s\n", sourceLabel, projectLabel, utils.PtrString(resp.Id)) + } + return nil + }) +} diff --git a/internal/cmd/volume/backup/create/create_test.go b/internal/cmd/volume/backup/create/create_test.go new file mode 100644 index 000000000..3b7d432e6 --- /dev/null +++ b/internal/cmd/volume/backup/create/create_test.go @@ -0,0 +1,277 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/iaas" +) + +const ( + testRegion = "eu01" + testName = "my-backup" + testSourceType = "volume" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSourceId = uuid.NewString() + testLabels = map[string]string{"key1": "value1"} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + sourceIdFlag: testSourceId, + sourceTypeFlag: testSourceType, + nameFlag: testName, + labelsFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + SourceID: testSourceId, + SourceType: testSourceType, + Name: utils.Ptr(testName), + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiCreateBackupRequest)) iaas.ApiCreateBackupRequest { + request := testClient.CreateBackup(testCtx, testProjectId, testRegion) + + createPayload := iaas.NewCreateBackupPayloadWithDefaults() + createPayload.Name = utils.Ptr(testName) + createPayload.Labels = &map[string]interface{}{ + "key1": "value1", + } + createPayload.Source = &iaas.BackupSource{ + Id: &testSourceId, + Type: utils.Ptr(testSourceType), + } + + request = request.CreateBackupPayload(*createPayload) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no source id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, sourceIdFlag) + }), + isValid: false, + }, + { + description: "no source type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, sourceTypeFlag) + }), + isValid: false, + }, + { + description: "invalid source type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sourceTypeFlag] = "invalid" + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "only required flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + model.Labels = make(map[string]string) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiCreateBackupRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + backupId := "test-backup-id" + + type args struct { + outputFormat string + async bool + sourceLabel string + projectLabel string + backup *iaas.Backup + } + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty backup", + args: args{}, + wantErr: true, + }, + { + name: "backup is nil", + args: args{ + backup: nil, + }, + wantErr: true, + }, + { + name: "minimal backup", + args: args{ + backup: &iaas.Backup{ + Id: &backupId, + }, + sourceLabel: "test-source", + projectLabel: "test-project", + }, + wantErr: false, + }, + { + name: "async mode", + args: args{ + backup: &iaas.Backup{ + Id: &backupId, + }, + sourceLabel: "test-source", + projectLabel: "test-project", + async: true, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + backup: &iaas.Backup{ + Id: &backupId, + }, + outputFormat: print.JSONOutputFormat, + }, + wantErr: false, + }, + { + name: "yaml output", + args: args{ + backup: &iaas.Backup{ + Id: &backupId, + }, + outputFormat: print.YAMLOutputFormat, + }, + wantErr: false, + }, + } + + p := print.NewPrinter() + cmd := NewCmd(&types.CmdParams{Printer: p}) + p.Cmd = cmd + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.sourceLabel, tt.args.projectLabel, tt.args.backup); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/volume/backup/delete/delete.go b/internal/cmd/volume/backup/delete/delete.go new file mode 100644 index 000000000..fdb2fe458 --- /dev/null +++ b/internal/cmd/volume/backup/delete/delete.go @@ -0,0 +1,118 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + + iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" +) + +const ( + backupIdArg = "BACKUP_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + BackupId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", backupIdArg), + Short: "Deletes a backup", + Long: "Deletes a backup by its ID.", + Args: args.SingleArg(backupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a backup with ID "xxx"`, "$ stackit volume backup delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + backupLabel, err := iaasutils.GetBackupName(ctx, apiClient, model.ProjectId, model.Region, model.BackupId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get backup name: %v", err) + } + + prompt := fmt.Sprintf("Are you sure you want to delete backup %q? (This cannot be undone)", backupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete backup: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting backup", func() error { + _, err = wait.DeleteBackupWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.BackupId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for backup deletion: %w", err) + } + } + + if model.Async { + params.Printer.Outputf("Triggered deletion of backup %q\n", backupLabel) + } else { + params.Printer.Outputf("Deleted backup %q\n", backupLabel) + } + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + BackupId: backupId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteBackupRequest { + req := apiClient.DeleteBackup(ctx, model.ProjectId, model.Region, model.BackupId) + return req +} diff --git a/internal/cmd/volume/backup/delete/delete_test.go b/internal/cmd/volume/backup/delete/delete_test.go new file mode 100644 index 000000000..26623975f --- /dev/null +++ b/internal/cmd/volume/backup/delete/delete_test.go @@ -0,0 +1,149 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testBackupId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testBackupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + BackupId: testBackupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteBackupRequest)) iaas.ApiDeleteBackupRequest { + request := testClient.DeleteBackup(testCtx, testProjectId, testRegion, testBackupId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiDeleteBackupRequest + }{ + { + 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/volume/backup/describe/describe.go b/internal/cmd/volume/backup/describe/describe.go new file mode 100644 index 000000000..78cc4790e --- /dev/null +++ b/internal/cmd/volume/backup/describe/describe.go @@ -0,0 +1,137 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + backupIdArg = "BACKUP_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + BackupId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", backupIdArg), + Short: "Describes a backup", + Long: "Describes a backup by its ID.", + Args: args.SingleArg(backupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a backup with ID "xxx"`, + "$ stackit volume backup describe xxx"), + examples.NewExample( + `Get details of a backup with ID "xxx" in JSON format`, + "$ stackit volume backup describe xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + backup, err := req.Execute() + if err != nil { + return fmt.Errorf("get backup details: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, backup) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + BackupId: backupId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetBackupRequest { + req := apiClient.GetBackup(ctx, model.ProjectId, model.Region, model.BackupId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, backup *iaas.Backup) error { + if backup == nil { + return fmt.Errorf("backup response is empty") + } + + return p.OutputResult(outputFormat, backup, func() error { + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(backup.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(backup.Name)) + table.AddSeparator() + table.AddRow("SIZE", utils.PtrGigaByteSizeDefault(backup.Size, "n/a")) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(backup.Status)) + table.AddSeparator() + table.AddRow("SNAPSHOT ID", utils.PtrString(backup.SnapshotId)) + table.AddSeparator() + table.AddRow("VOLUME ID", utils.PtrString(backup.VolumeId)) + table.AddSeparator() + table.AddRow("AVAILABILITY ZONE", utils.PtrString(backup.AvailabilityZone)) + table.AddSeparator() + + if backup.Labels != nil && len(*backup.Labels) > 0 { + var labels []string + for key, value := range *backup.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(backup.CreatedAt)) + table.AddSeparator() + table.AddRow("UPDATED AT", utils.ConvertTimePToDateTimeString(backup.UpdatedAt)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/volume/backup/describe/describe_test.go b/internal/cmd/volume/backup/describe/describe_test.go new file mode 100644 index 000000000..8ffe6e03e --- /dev/null +++ b/internal/cmd/volume/backup/describe/describe_test.go @@ -0,0 +1,186 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testBackupId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testBackupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + BackupId: testBackupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetBackupRequest)) iaas.ApiGetBackupRequest { + request := testClient.GetBackup(testCtx, testProjectId, testRegion, testBackupId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiGetBackupRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + backup *iaas.Backup + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "backup as argument", + args: args{ + backup: &iaas.Backup{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.backup); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/volume/backup/list/list.go b/internal/cmd/volume/backup/list/list.go new file mode 100644 index 000000000..9da088d13 --- /dev/null +++ b/internal/cmd/volume/backup/list/list.go @@ -0,0 +1,177 @@ +package list + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + limitFlag = "limit" + labelSelectorFlag = "label-selector" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 + LabelSelector *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all backups", + Long: "Lists all backups in a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all backups`, + "$ stackit volume backup list"), + examples.NewExample( + `List all backups in JSON format`, + "$ stackit volume backup list --output-format json"), + examples.NewExample( + `List up to 10 backups`, + "$ stackit volume backup list --limit 10"), + examples.NewExample( + `List backups with specific labels`, + "$ stackit volume backup list --label-selector key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get backups: %w", err) + } + if resp.Items == nil || len(*resp.Items) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + params.Printer.Info("No backups found for project %s\n", projectLabel) + return nil + } + backups := *resp.Items + + // Truncate output + if model.Limit != nil && len(backups) > int(*model.Limit) { + backups = backups[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, backups) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().String(labelSelectorFlag, "", "Filter backups by labels") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + labelSelector := flags.FlagToStringPointer(p, cmd, labelSelectorFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + LabelSelector: labelSelector, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListBackupsRequest { + req := apiClient.ListBackups(ctx, model.ProjectId, model.Region) + + if model.LabelSelector != nil { + req = req.LabelSelector(*model.LabelSelector) + } + + return req +} + +func outputResult(p *print.Printer, outputFormat string, backups []iaas.Backup) error { + if backups == nil { + return fmt.Errorf("backups is empty") + } + + return p.OutputResult(outputFormat, backups, func() error { + table := tables.NewTable() + table.SetHeader("ID", "NAME", "SIZE", "STATUS", "SNAPSHOT ID", "VOLUME ID", "AVAILABILITY ZONE", "LABELS", "CREATED AT", "UPDATED AT") + + for _, backup := range backups { + var labelsString string + if backup.Labels != nil { + var labels []string + for key, value := range *backup.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + labelsString = strings.Join(labels, ", ") + } + + table.AddRow( + utils.PtrString(backup.Id), + utils.PtrString(backup.Name), + utils.PtrGigaByteSizeDefault(backup.Size, "n/a"), + utils.PtrString(backup.Status), + utils.PtrString(backup.SnapshotId), + utils.PtrString(backup.VolumeId), + utils.PtrString(backup.AvailabilityZone), + labelsString, + utils.ConvertTimePToDateTimeString(backup.CreatedAt), + utils.ConvertTimePToDateTimeString(backup.UpdatedAt), + ) + table.AddSeparator() + } + + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/volume/backup/list/list_test.go b/internal/cmd/volume/backup/list/list_test.go new file mode 100644 index 000000000..0722dd90d --- /dev/null +++ b/internal/cmd/volume/backup/list/list_test.go @@ -0,0 +1,201 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/iaas" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + limitFlag: "10", + labelSelectorFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + LabelSelector: utils.Ptr("key1=value1"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListBackupsRequest)) iaas.ApiListBackupsRequest { + request := testClient.ListBackups(testCtx, testProjectId, testRegion) + request = request.LabelSelector("key1=value1") + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListBackupsRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + backups []iaas.Backup + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty backup in slice", + args: args{ + backups: []iaas.Backup{{}}, + }, + wantErr: false, + }, + { + name: "empty slice", + args: args{ + backups: []iaas.Backup{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.backups); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/volume/backup/restore/restore.go b/internal/cmd/volume/backup/restore/restore.go new file mode 100644 index 000000000..d98478dee --- /dev/null +++ b/internal/cmd/volume/backup/restore/restore.go @@ -0,0 +1,131 @@ +package restore + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + + iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" +) + +const ( + backupIdArg = "BACKUP_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + BackupId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("restore %s", backupIdArg), + Short: "Restores a backup", + Long: "Restores a backup by its ID.", + Args: args.SingleArg(backupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Restore a backup with ID "xxx"`, "$ stackit volume backup restore xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + backupLabel, err := iaasutils.GetBackupName(ctx, apiClient, model.ProjectId, model.Region, model.BackupId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get backup details: %v", err) + } + + // Get source details for labels + var sourceLabel string + backup, err := apiClient.GetBackup(ctx, model.ProjectId, model.Region, model.BackupId).Execute() + if err == nil && backup != nil && backup.VolumeId != nil { + sourceLabel = *backup.VolumeId + name, err := iaasutils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, *backup.VolumeId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get volume details: %v", err) + } else if name != "" { + sourceLabel = name + } + } + + prompt := fmt.Sprintf("Are you sure you want to restore %q with backup %q? (This cannot be undone)", sourceLabel, backupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("restore backup: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Restoring backup", func() error { + _, err = wait.RestoreBackupWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.BackupId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for backup restore: %w", err) + } + } + + if model.Async { + params.Printer.Outputf("Triggered restore of %q with %q in %q\n", sourceLabel, backupLabel, model.ProjectId) + } else { + params.Printer.Outputf("Restored %q with %q in %q\n", sourceLabel, backupLabel, model.ProjectId) + } + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + BackupId: backupId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRestoreBackupRequest { + req := apiClient.RestoreBackup(ctx, model.ProjectId, model.Region, model.BackupId) + return req +} diff --git a/internal/cmd/volume/backup/restore/restore_test.go b/internal/cmd/volume/backup/restore/restore_test.go new file mode 100644 index 000000000..1bd31ce17 --- /dev/null +++ b/internal/cmd/volume/backup/restore/restore_test.go @@ -0,0 +1,149 @@ +package restore + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testBackupId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testBackupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + BackupId: testBackupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiRestoreBackupRequest)) iaas.ApiRestoreBackupRequest { + request := testClient.RestoreBackup(testCtx, testProjectId, testRegion, testBackupId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiRestoreBackupRequest + }{ + { + 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/volume/backup/update/update.go b/internal/cmd/volume/backup/update/update.go new file mode 100644 index 000000000..4a61e582a --- /dev/null +++ b/internal/cmd/volume/backup/update/update.go @@ -0,0 +1,141 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + backupIdArg = "BACKUP_ID" + nameFlag = "name" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + BackupId string + Name *string + Labels map[string]string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", backupIdArg), + Short: "Updates a backup", + Long: "Updates a backup by its ID.", + Args: args.SingleArg(backupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the name of a backup with ID "xxx"`, + "$ stackit volume backup update xxx --name new-name"), + examples.NewExample( + `Update the labels of a backup with ID "xxx"`, + "$ stackit volume backup update xxx --labels key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + backupLabel, err := iaasutils.GetBackupName(ctx, apiClient, model.ProjectId, model.Region, model.BackupId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get backup name: %v", err) + } + + prompt := fmt.Sprintf("Are you sure you want to update backup %q? (This cannot be undone)", model.BackupId) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update backup: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, backupLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Name of the backup") + cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) + if labels == nil { + labels = &map[string]string{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + BackupId: backupId, + Name: name, + Labels: *labels, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateBackupRequest { + req := apiClient.UpdateBackup(ctx, model.ProjectId, model.Region, model.BackupId) + + payload := iaas.UpdateBackupPayload{ + Name: model.Name, + Labels: utils.ConvertStringMapToInterfaceMap(utils.Ptr(model.Labels)), + } + + req = req.UpdateBackupPayload(payload) + return req +} + +func outputResult(p *print.Printer, outputFormat, backupLabel string, backup *iaas.Backup) error { + if backup == nil { + return fmt.Errorf("backup response is empty") + } + + return p.OutputResult(outputFormat, backup, func() error { + p.Outputf("Updated backup %q\n", backupLabel) + return nil + }) +} diff --git a/internal/cmd/volume/backup/update/update_test.go b/internal/cmd/volume/backup/update/update_test.go new file mode 100644 index 000000000..94860245a --- /dev/null +++ b/internal/cmd/volume/backup/update/update_test.go @@ -0,0 +1,155 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/iaas" +) + +const ( + testRegion = "eu01" + testName = "test-backup" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testBackupId = uuid.NewString() + testLabels = map[string]string{"key1": "value1"} +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testBackupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + nameFlag: testName, + labelsFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + Region: testRegion, + }, + BackupId: testBackupId, + Name: utils.Ptr(testName), + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateBackupRequest)) iaas.ApiUpdateBackupRequest { + request := testClient.UpdateBackup(testCtx, testProjectId, testRegion, testBackupId) + payload := iaas.NewUpdateBackupPayloadWithDefaults() + payload.Name = utils.Ptr(testName) + + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(testLabels)) + + request = request.UpdateBackupPayload(*payload) + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateBackupRequest + }{ + { + 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/volume/create/create.go b/internal/cmd/volume/create/create.go index 90b20d813..f0c672602 100644 --- a/internal/cmd/volume/create/create.go +++ b/internal/cmd/volume/create/create.go @@ -2,10 +2,13 @@ package create import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,8 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -45,7 +46,7 @@ type inputModel struct { SourceType *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Creates a volume", @@ -69,31 +70,29 @@ func NewCmd(p *print.Printer) *cobra.Command { `$ stackit volume create --availability-zone eu01-1 --performance-class storage_premium_perf1 --size 64`, ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to create a volume for project %q?", projectLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to create a volume for project %q?", projectLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -106,16 +105,16 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Creating volume") - _, err = wait.CreateVolumeWaitHandler(ctx, apiClient, model.ProjectId, volumeId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Creating volume", func() error { + _, err = wait.CreateVolumeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, volumeId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for volume creation: %w", err) } - s.Stop() } - return outputResult(p, model, projectLabel, resp) + return outputResult(params.Printer, model, projectLabel, resp) }, } configureFlags(cmd) @@ -136,7 +135,7 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &cliErr.ProjectIdError{} @@ -154,39 +153,22 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { SourceType: flags.FlagToStringPointer(p, cmd, sourceTypeFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateVolumeRequest { - req := apiClient.CreateVolume(ctx, model.ProjectId) + req := apiClient.CreateVolume(ctx, model.ProjectId, model.Region) source := &iaas.VolumeSource{ Id: model.SourceId, Type: model.SourceType, } - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } - payload := iaas.CreateVolumePayload{ AvailabilityZone: model.AvailabilityZone, Name: model.Name, Description: model.Description, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), PerformanceClass: model.PerformanceClass, Size: model.Size, } @@ -202,25 +184,12 @@ func outputResult(p *print.Printer, model *inputModel, projectLabel string, volu if volume == nil { return fmt.Errorf("volume response is empty") } - switch model.OutputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(volume, "", " ") - if err != nil { - return fmt.Errorf("marshal volume: %w", err) + return p.OutputResult(model.OutputFormat, volume, func() error { + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal volume: %w", err) - } - p.Outputln(string(details)) - - return nil - default: - p.Outputf("Created volume for project %q.\nVolume ID: %s\n", projectLabel, utils.PtrString(volume.Id)) + p.Outputf("%s volume for project %q.\nVolume ID: %s\n", operationState, projectLabel, utils.PtrString(volume.Id)) return nil - } + }) } diff --git a/internal/cmd/volume/create/create_test.go b/internal/cmd/volume/create/create_test.go index 71f3e1697..ad088cdb8 100644 --- a/internal/cmd/volume/create/create_test.go +++ b/internal/cmd/volume/create/create_test.go @@ -4,16 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -25,7 +31,9 @@ var testSourceId = uuid.NewString() func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + availabilityZoneFlag: "eu01-1", nameFlag: "example-volume-name", descriptionFlag: "example-volume-description", @@ -46,6 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, AvailabilityZone: utils.Ptr("eu01-1"), Name: utils.Ptr("example-volume-name"), @@ -65,7 +74,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiCreateVolumeRequest)) iaas.ApiCreateVolumeRequest { - request := testClient.CreateVolume(testCtx, testProjectId) + request := testClient.CreateVolume(testCtx, testProjectId, testRegion) request = request.CreateVolumePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -74,7 +83,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiCreateVolumeRequest)) iaas.Api } func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateVolumeRequest)) iaas.ApiCreateVolumeRequest { - request := testClient.CreateVolume(testCtx, testProjectId) + request := testClient.CreateVolume(testCtx, testProjectId, testRegion) request = request.CreateVolumePayload(iaas.CreateVolumePayload{ AvailabilityZone: utils.Ptr("eu01-1"), }) @@ -108,6 +117,7 @@ func fixturePayload(mods ...func(payload *iaas.CreateVolumePayload)) iaas.Create func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -155,21 +165,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -201,46 +211,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -262,6 +233,7 @@ func TestBuildRequest(t *testing.T) { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, AvailabilityZone: utils.Ptr("eu01-1"), }, @@ -310,7 +282,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.model, tt.args.projectLabel, tt.args.volume); (err != nil) != tt.wantErr { diff --git a/internal/cmd/volume/delete/delete.go b/internal/cmd/volume/delete/delete.go index d07a9e8f5..11984713b 100644 --- a/internal/cmd/volume/delete/delete.go +++ b/internal/cmd/volume/delete/delete.go @@ -4,8 +4,13 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -13,8 +18,6 @@ import ( iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" - "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" "github.com/spf13/cobra" ) @@ -28,7 +31,7 @@ type inputModel struct { VolumeId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("delete %s", volumeIdArg), Short: "Deletes a volume", @@ -45,31 +48,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - volumeLabel := model.VolumeId - volumeName, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId) if err != nil { - p.Debug(print.ErrorLevel, "get volume name: %v", err) - } else if volumeName != "" { - volumeLabel = volumeName + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) + volumeLabel = model.VolumeId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete volume %q?", volumeLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to delete volume %q?", volumeLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -81,20 +80,20 @@ func NewCmd(p *print.Printer) *cobra.Command { // Wait for async operation, if async mode not enabled if !model.Async { - s := spinner.New(p) - s.Start("Deleting volume") - _, err = wait.DeleteVolumeWaitHandler(ctx, apiClient, model.ProjectId, model.VolumeId).WaitWithContext(ctx) + err := spinner.Run(params.Printer, "Deleting volume", func() error { + _, err = wait.DeleteVolumeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId).WaitWithContext(ctx) + return err + }) if err != nil { return fmt.Errorf("wait for volume deletion: %w", err) } - s.Stop() } operationState := "Deleted" if model.Async { operationState = "Triggered deletion of" } - p.Info("%s volume %q\n", operationState, volumeLabel) + params.Printer.Info("%s volume %q\n", operationState, volumeLabel) return nil }, } @@ -106,7 +105,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} + return nil, &cliErr.ProjectIdError{} } model := inputModel{ @@ -114,18 +113,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu VolumeId: volumeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteVolumeRequest { - return apiClient.DeleteVolume(ctx, model.ProjectId, model.VolumeId) + return apiClient.DeleteVolume(ctx, model.ProjectId, model.Region, model.VolumeId) } diff --git a/internal/cmd/volume/delete/delete_test.go b/internal/cmd/volume/delete/delete_test.go index 5648c374c..42d63db2b 100644 --- a/internal/cmd/volume/delete/delete_test.go +++ b/internal/cmd/volume/delete/delete_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -13,7 +13,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -34,7 +36,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -47,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, VolumeId: testVolumeId, } @@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiDeleteVolumeRequest)) iaas.ApiDeleteVolumeRequest { - request := testClient.DeleteVolume(testCtx, testProjectId, testVolumeId) + request := testClient.DeleteVolume(testCtx, testProjectId, testRegion, testVolumeId) for _, mod := range mods { mod(&request) } @@ -101,7 +105,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -109,7 +113,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -117,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -137,54 +141,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } diff --git a/internal/cmd/volume/describe/describe.go b/internal/cmd/volume/describe/describe.go index e77073ea2..e8c009a65 100644 --- a/internal/cmd/volume/describe/describe.go +++ b/internal/cmd/volume/describe/describe.go @@ -2,11 +2,12 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -16,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ type inputModel struct { VolumeId string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", volumeIdArg), Short: "Shows details of a volume", @@ -48,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read volume: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -85,44 +85,19 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu VolumeId: volumeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetVolumeRequest { - return apiClient.GetVolume(ctx, model.ProjectId, model.VolumeId) + return apiClient.GetVolume(ctx, model.ProjectId, model.Region, model.VolumeId) } func outputResult(p *print.Printer, outputFormat string, volume *iaas.Volume) error { if volume == nil { return fmt.Errorf("volume response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(volume, "", " ") - if err != nil { - return fmt.Errorf("marshal volume: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal volume: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, volume, func() error { table := tables.NewTable() table.AddRow("ID", utils.PtrString(volume.Id)) table.AddSeparator() @@ -162,5 +137,5 @@ func outputResult(p *print.Printer, outputFormat string, volume *iaas.Volume) er return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/volume/describe/describe_test.go b/internal/cmd/volume/describe/describe_test.go index 3de0f688b..6aa31f41e 100644 --- a/internal/cmd/volume/describe/describe_test.go +++ b/internal/cmd/volume/describe/describe_test.go @@ -4,15 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -33,7 +39,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -46,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, Verbosity: globalflags.VerbosityDefault, + Region: testRegion, }, VolumeId: testVolumeId, } @@ -56,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetVolumeRequest)) iaas.ApiGetVolumeRequest { - request := testClient.GetVolume(testCtx, testProjectId, testVolumeId) + request := testClient.GetVolume(testCtx, testProjectId, testRegion, testVolumeId) for _, mod := range mods { mod(&request) } @@ -100,7 +108,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -108,7 +116,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -116,7 +124,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -136,54 +144,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -240,7 +201,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.volume); (err != nil) != tt.wantErr { diff --git a/internal/cmd/volume/list/list.go b/internal/cmd/volume/list/list.go index 02c643014..fa4b2c1f0 100644 --- a/internal/cmd/volume/list/list.go +++ b/internal/cmd/volume/list/list.go @@ -2,11 +2,13 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +19,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -31,7 +32,7 @@ type inputModel struct { LabelSelector *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all volumes of a project", @@ -55,15 +56,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit volume list --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -76,12 +77,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No volumes found for project %q\n", projectLabel) + params.Printer.Info("No volumes found for project %q\n", projectLabel) return nil } @@ -91,7 +92,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -103,7 +104,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(labelSelectorFlag, "", "Filter by label") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -123,20 +124,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListVolumesRequest { - req := apiClient.ListVolumes(ctx, model.ProjectId) + req := apiClient.ListVolumes(ctx, model.ProjectId, model.Region) if model.LabelSelector != nil { req = req.LabelSelector(*model.LabelSelector) } @@ -145,28 +138,12 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat string, volumes []iaas.Volume) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(volumes, "", " ") - if err != nil { - return fmt.Errorf("marshal volume: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(volumes, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal volume: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, volumes, func() error { table := tables.NewTable() table.SetHeader("ID", "Name", "Status", "Server", "Availability Zone", "Size (GB)") - for _, volume := range volumes { + for i := range volumes { + volume := volumes[i] table.AddRow( utils.PtrString(volume.Id), utils.PtrString(volume.Name), @@ -180,5 +157,5 @@ func outputResult(p *print.Printer, outputFormat string, volumes []iaas.Volume) p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/volume/list/list_test.go b/internal/cmd/volume/list/list_test.go index 77cc3808b..924acee29 100644 --- a/internal/cmd/volume/list/list_test.go +++ b/internal/cmd/volume/list/list_test.go @@ -4,16 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -24,7 +30,9 @@ var testLabelSelector = "label" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", labelSelectorFlag: testLabelSelector, } @@ -39,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), LabelSelector: utils.Ptr(testLabelSelector), @@ -50,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListVolumesRequest)) iaas.ApiListVolumesRequest { - request := testClient.ListVolumes(testCtx, testProjectId) + request := testClient.ListVolumes(testCtx, testProjectId, testRegion) request = request.LabelSelector(testLabelSelector) for _, mod := range mods { mod(&request) @@ -61,6 +70,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListVolumesRequest)) iaas.ApiL func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -84,21 +94,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -130,46 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -226,7 +197,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.volumes); (err != nil) != tt.wantErr { diff --git a/internal/cmd/volume/performance-class/describe/describe.go b/internal/cmd/volume/performance-class/describe/describe.go index ade26f34a..c75a3cb50 100644 --- a/internal/cmd/volume/performance-class/describe/describe.go +++ b/internal/cmd/volume/performance-class/describe/describe.go @@ -2,11 +2,12 @@ package describe import ( "context" - "encoding/json" "fmt" "strings" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" @@ -16,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -30,7 +30,7 @@ type inputModel struct { VolumePerformanceClass string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("describe %s", volumePerformanceClassArg), Short: "Shows details of a volume performance class", @@ -48,13 +48,13 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -66,7 +66,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("read volume performance class: %w", err) } - return outputResult(p, model.OutputFormat, resp) + return outputResult(params.Printer, model.OutputFormat, resp) }, } return cmd @@ -85,44 +85,19 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu VolumePerformanceClass: volumePerformanceClass, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetVolumePerformanceClassRequest { - return apiClient.GetVolumePerformanceClass(ctx, model.ProjectId, model.VolumePerformanceClass) + return apiClient.GetVolumePerformanceClass(ctx, model.ProjectId, model.Region, model.VolumePerformanceClass) } func outputResult(p *print.Printer, outputFormat string, performanceClass *iaas.VolumePerformanceClass) error { if performanceClass == nil { return fmt.Errorf("performanceClass response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(performanceClass, "", " ") - if err != nil { - return fmt.Errorf("marshal volume performance class: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(performanceClass, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal volume performance class: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, performanceClass, func() error { table := tables.NewTable() table.AddRow("NAME", utils.PtrString(performanceClass.Name)) table.AddSeparator() @@ -147,5 +122,5 @@ func outputResult(p *print.Printer, outputFormat string, performanceClass *iaas. return fmt.Errorf("render table: %w", err) } return nil - } + }) } diff --git a/internal/cmd/volume/performance-class/describe/describe_test.go b/internal/cmd/volume/performance-class/describe/describe_test.go index 90ba0da3c..aafac6197 100644 --- a/internal/cmd/volume/performance-class/describe/describe_test.go +++ b/internal/cmd/volume/performance-class/describe/describe_test.go @@ -4,15 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -33,7 +40,8 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, } for _, mod := range mods { mod(flagValues) @@ -45,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, VolumePerformanceClass: testVolumePerformanceClass, @@ -56,7 +65,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiGetVolumePerformanceClassRequest)) iaas.ApiGetVolumePerformanceClassRequest { - request := testClient.GetVolumePerformanceClass(testCtx, testProjectId, testVolumePerformanceClass) + request := testClient.GetVolumePerformanceClass(testCtx, testProjectId, testRegion, testVolumePerformanceClass) for _, mod := range mods { mod(&request) } @@ -100,7 +109,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -108,7 +117,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -116,7 +125,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -130,54 +139,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -234,7 +196,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.performanceClass); (err != nil) != tt.wantErr { diff --git a/internal/cmd/volume/performance-class/list/list.go b/internal/cmd/volume/performance-class/list/list.go index 4449a5f3e..ae62dd65d 100644 --- a/internal/cmd/volume/performance-class/list/list.go +++ b/internal/cmd/volume/performance-class/list/list.go @@ -2,10 +2,12 @@ package list import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -16,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" "github.com/stackitcloud/stackit-cli/internal/pkg/tables" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -32,7 +33,7 @@ type inputModel struct { LabelSelector *string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "Lists all volume performance classes for a project", @@ -56,15 +57,15 @@ func NewCmd(p *print.Printer) *cobra.Command { "$ stackit volume performance-class list --limit 10", ), ), - RunE: func(cmd *cobra.Command, _ []string) error { + RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } @@ -77,12 +78,12 @@ func NewCmd(p *print.Printer) *cobra.Command { } if resp.Items == nil || len(*resp.Items) == 0 { - projectLabel, err := projectname.GetProjectName(ctx, p, cmd) + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) if err != nil { - p.Debug(print.ErrorLevel, "get project name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) projectLabel = model.ProjectId } - p.Info("No volume performance class found for project %q\n", projectLabel) + params.Printer.Info("No volume performance class found for project %q\n", projectLabel) return nil } @@ -92,7 +93,7 @@ func NewCmd(p *print.Printer) *cobra.Command { items = items[:*model.Limit] } - return outputResult(p, model.OutputFormat, items) + return outputResult(params.Printer, model.OutputFormat, items) }, } configureFlags(cmd) @@ -104,7 +105,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(labelSelectorFlag, "", "Filter by label") } -func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { globalFlags := globalflags.Parse(p, cmd) if globalFlags.ProjectId == "" { return nil, &errors.ProjectIdError{} @@ -124,20 +125,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListVolumePerformanceClassesRequest { - req := apiClient.ListVolumePerformanceClasses(ctx, model.ProjectId) + req := apiClient.ListVolumePerformanceClasses(ctx, model.ProjectId, model.Region) if model.LabelSelector != nil { req = req.LabelSelector(*model.LabelSelector) } @@ -146,24 +139,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli } func outputResult(p *print.Printer, outputFormat string, performanceClasses []iaas.VolumePerformanceClass) error { - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(performanceClasses, "", " ") - if err != nil { - return fmt.Errorf("marshal volume performance class: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(performanceClasses, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal volume performance class: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, performanceClasses, func() error { table := tables.NewTable() table.SetHeader("Name", "Description") @@ -174,5 +150,5 @@ func outputResult(p *print.Printer, outputFormat string, performanceClasses []ia p.Outputln(table.Render()) return nil - } + }) } diff --git a/internal/cmd/volume/performance-class/list/list_test.go b/internal/cmd/volume/performance-class/list/list_test.go index 9e23bbdfd..79a1b29c8 100644 --- a/internal/cmd/volume/performance-class/list/list_test.go +++ b/internal/cmd/volume/performance-class/list/list_test.go @@ -4,16 +4,22 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -24,7 +30,9 @@ var testLabelSelector = "label" func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + limitFlag: "10", labelSelectorFlag: testLabelSelector, } @@ -39,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ Verbosity: globalflags.VerbosityDefault, ProjectId: testProjectId, + Region: testRegion, }, Limit: utils.Ptr(int64(10)), LabelSelector: utils.Ptr(testLabelSelector), @@ -50,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiListVolumePerformanceClassesRequest)) iaas.ApiListVolumePerformanceClassesRequest { - request := testClient.ListVolumePerformanceClasses(testCtx, testProjectId) + request := testClient.ListVolumePerformanceClasses(testCtx, testProjectId, testRegion) request = request.LabelSelector(testLabelSelector) for _, mod := range mods { mod(&request) @@ -61,6 +70,7 @@ func fixtureRequest(mods ...func(request *iaas.ApiListVolumePerformanceClassesRe func TestParseInput(t *testing.T) { tests := []struct { description string + argValues []string flagValues map[string]string isValid bool expectedModel *inputModel @@ -84,21 +94,21 @@ func TestParseInput(t *testing.T) { { description: "project id missing", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, { description: "project id invalid 1", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, { description: "project id invalid 2", flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -130,46 +140,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - p := print.NewPrinter() - cmd := NewCmd(p) - 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(p, cmd) - 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) - } + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) }) } } @@ -226,7 +197,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.performanceClasses); (err != nil) != tt.wantErr { diff --git a/internal/cmd/volume/performance-class/performance_class.go b/internal/cmd/volume/performance-class/performance_class.go index 4f159606e..dd00fe2d6 100644 --- a/internal/cmd/volume/performance-class/performance_class.go +++ b/internal/cmd/volume/performance-class/performance_class.go @@ -4,13 +4,13 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/volume/performance-class/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/performance-class/list" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "performance-class", Short: "Provides functionality for volume performance classes available inside a project", @@ -18,11 +18,11 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) } diff --git a/internal/cmd/volume/resize/resize.go b/internal/cmd/volume/resize/resize.go index 7f966a2e3..1207eee46 100644 --- a/internal/cmd/volume/resize/resize.go +++ b/internal/cmd/volume/resize/resize.go @@ -4,6 +4,10 @@ import ( "context" "fmt" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -13,7 +17,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/cobra" ) @@ -30,7 +33,7 @@ type inputModel struct { Size *int64 } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("resize %s", volumeIdArg), Short: "Resizes a volume", @@ -44,29 +47,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId) if err != nil { - p.Debug(print.ErrorLevel, "get volume name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) volumeLabel = model.VolumeId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to resize volume %q?", volumeLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to resize volume %q?", volumeLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -76,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("resize volume: %w", err) } - p.Outputf("Resized volume %q.\n", volumeLabel) + params.Printer.Outputf("Resized volume %q.\n", volumeLabel) return nil }, } @@ -105,20 +106,12 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu VolumeId: volumeId, } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiResizeVolumeRequest { - req := apiClient.ResizeVolume(ctx, model.ProjectId, model.VolumeId) + req := apiClient.ResizeVolume(ctx, model.ProjectId, model.Region, model.VolumeId) payload := iaas.ResizeVolumePayload{ Size: model.Size, diff --git a/internal/cmd/volume/resize/resize_test.go b/internal/cmd/volume/resize/resize_test.go index 15dd9c757..a45ecb6ea 100644 --- a/internal/cmd/volume/resize/resize_test.go +++ b/internal/cmd/volume/resize/resize_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -14,7 +16,9 @@ import ( "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -36,8 +40,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ - sizeFlag: "10", - projectIdFlag: testProjectId, + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + sizeFlag: "10", } for _, mod := range mods { mod(flagValues) @@ -49,6 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, Size: utils.Ptr(int64(10)), @@ -61,7 +68,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiResizeVolumeRequest)) iaas.ApiResizeVolumeRequest { - request := testClient.ResizeVolume(testCtx, testProjectId, testVolumeId) + request := testClient.ResizeVolume(testCtx, testProjectId, testRegion, testVolumeId) request = request.ResizeVolumePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -104,7 +111,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -112,7 +119,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -120,7 +127,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -152,7 +159,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) diff --git a/internal/cmd/volume/snapshot/create/create.go b/internal/cmd/volume/snapshot/create/create.go new file mode 100644 index 000000000..836794612 --- /dev/null +++ b/internal/cmd/volume/snapshot/create/create.go @@ -0,0 +1,162 @@ +package create + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" +) + +const ( + volumeIdFlag = "volume-id" + nameFlag = "name" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + VolumeID string + Name *string + Labels map[string]string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a snapshot from a volume", + Long: "Creates a snapshot from a volume.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a snapshot from a volume with ID "xxx"`, + "$ stackit volume snapshot create --volume-id xxx"), + examples.NewExample( + `Create a snapshot from a volume with ID "xxx" and name "my-snapshot"`, + "$ stackit volume snapshot create --volume-id xxx --name my-snapshot"), + examples.NewExample( + `Create a snapshot from a volume with ID "xxx" and labels`, + "$ stackit volume snapshot create --volume-id xxx --labels key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Get volume name for label + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.VolumeID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) + volumeLabel = model.VolumeID + } + + prompt := fmt.Sprintf("Are you sure you want to create snapshot from volume %q? (This cannot be undone)", volumeLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create snapshot: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + resp, err = spinner.Run2(params.Printer, "Creating snapshot", func() (*iaas.Snapshot, error) { + return wait.CreateSnapshotWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Id).WaitWithContext(ctx) + }) + if err != nil { + return fmt.Errorf("wait for snapshot creation: %w", err) + } + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + params.Printer.Outputf("%s snapshot of %q in %q. Snapshot ID: %s\n", operationState, volumeLabel, projectLabel, utils.PtrString(resp.Id)) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), volumeIdFlag, "ID of the volume from which a snapshot should be created") + cmd.Flags().String(nameFlag, "", "Name of the snapshot") + cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") + + err := flags.MarkFlagsRequired(cmd, volumeIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + volumeID := flags.FlagToStringValue(p, cmd, volumeIdFlag) + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) + if labels == nil { + labels = &map[string]string{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + VolumeID: volumeID, + Name: name, + Labels: *labels, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateSnapshotRequest { + req := apiClient.CreateSnapshot(ctx, model.ProjectId, model.Region) + payload := iaas.NewCreateSnapshotPayloadWithDefaults() + payload.VolumeId = &model.VolumeID + payload.Name = model.Name + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(model.Labels)) + + req = req.CreateSnapshotPayload(*payload) + return req +} diff --git a/internal/cmd/volume/snapshot/create/create_test.go b/internal/cmd/volume/snapshot/create/create_test.go new file mode 100644 index 000000000..7dbc2681f --- /dev/null +++ b/internal/cmd/volume/snapshot/create/create_test.go @@ -0,0 +1,173 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type testCtxKey struct{} + +const ( + testRegion = "eu01" + testName = "test-snapshot" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testVolumeId = uuid.NewString() + testLabels = map[string]string{"key1": "value1"} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + volumeIdFlag: testVolumeId, + nameFlag: testName, + labelsFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + VolumeID: testVolumeId, + Name: utils.Ptr(testName), + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiCreateSnapshotRequest)) iaas.ApiCreateSnapshotRequest { + request := testClient.CreateSnapshot(testCtx, testProjectId, testRegion) + payload := iaas.NewCreateSnapshotPayloadWithDefaults() + payload.VolumeId = &testVolumeId + payload.Name = utils.Ptr(testName) + + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(testLabels)) + + request = request.CreateSnapshotPayload(*payload) + 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", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no volume id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, volumeIdFlag) + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "volume id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[volumeIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "only required flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + model.Labels = make(map[string]string) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiCreateSnapshotRequest + }{ + { + 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/volume/snapshot/delete/delete.go b/internal/cmd/volume/snapshot/delete/delete.go new file mode 100644 index 000000000..9a60e5a13 --- /dev/null +++ b/internal/cmd/volume/snapshot/delete/delete.go @@ -0,0 +1,119 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" +) + +const ( + snapshotIdArg = "SNAPSHOT_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SnapshotId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", snapshotIdArg), + Short: "Deletes a snapshot", + Long: "Deletes a snapshot by its ID.", + Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a snapshot with ID "xxx"`, + "$ stackit volume snapshot delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get snapshot name for label + snapshotLabel, err := iaasUtils.GetSnapshotName(ctx, apiClient, model.ProjectId, model.Region, model.SnapshotId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) + snapshotLabel = model.SnapshotId + } + + prompt := fmt.Sprintf("Are you sure you want to delete snapshot %q? (This cannot be undone)", snapshotLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete snapshot: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + err := spinner.Run(params.Printer, "Deleting snapshot", func() error { + _, err = wait.DeleteSnapshotWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.SnapshotId).WaitWithContext(ctx) + return err + }) + if err != nil { + return fmt.Errorf("wait for snapshot deletion: %w", err) + } + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + params.Printer.Outputf("%s snapshot %q\n", operationState, snapshotLabel) + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotId: snapshotId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteSnapshotRequest { + return apiClient.DeleteSnapshot(ctx, model.ProjectId, model.Region, model.SnapshotId) +} diff --git a/internal/cmd/volume/snapshot/delete/delete_test.go b/internal/cmd/volume/snapshot/delete/delete_test.go new file mode 100644 index 000000000..6ffa3d880 --- /dev/null +++ b/internal/cmd/volume/snapshot/delete/delete_test.go @@ -0,0 +1,163 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSnapshotId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + SnapshotId: testSnapshotId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteSnapshotRequest)) iaas.ApiDeleteSnapshotRequest { + request := testClient.DeleteSnapshot(testCtx, testProjectId, testRegion, testSnapshotId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "snapshot id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiDeleteSnapshotRequest + }{ + { + 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/volume/snapshot/describe/describe.go b/internal/cmd/volume/snapshot/describe/describe.go new file mode 100644 index 000000000..85e9e0fe2 --- /dev/null +++ b/internal/cmd/volume/snapshot/describe/describe.go @@ -0,0 +1,132 @@ +package describe + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + snapshotIdArg = "SNAPSHOT_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SnapshotId string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", snapshotIdArg), + Short: "Describes a snapshot", + Long: "Describes a snapshot by its ID.", + Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a snapshot with ID "xxx"`, + "$ stackit volume snapshot describe xxx"), + examples.NewExample( + `Get details of a snapshot with ID "xxx" in JSON format`, + "$ stackit volume snapshot describe xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get snapshot details: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotId: snapshotId, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetSnapshotRequest { + return apiClient.GetSnapshot(ctx, model.ProjectId, model.Region, model.SnapshotId) +} + +func outputResult(p *print.Printer, outputFormat string, snapshot *iaas.Snapshot) error { + if snapshot == nil { + return fmt.Errorf("get snapshot response is empty") + } + + return p.OutputResult(outputFormat, snapshot, func() error { + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(snapshot.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(snapshot.Name)) + table.AddSeparator() + table.AddRow("SIZE", utils.PtrGigaByteSizeDefault(snapshot.Size, "n/a")) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(snapshot.Status)) + table.AddSeparator() + table.AddRow("VOLUME ID", utils.PtrString(snapshot.VolumeId)) + table.AddSeparator() + + if snapshot.Labels != nil && len(*snapshot.Labels) > 0 { + labels := []string{} + for key, value := range *snapshot.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(snapshot.CreatedAt)) + table.AddSeparator() + table.AddRow("UPDATED AT", utils.ConvertTimePToDateTimeString(snapshot.UpdatedAt)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + }) +} diff --git a/internal/cmd/volume/snapshot/describe/describe_test.go b/internal/cmd/volume/snapshot/describe/describe_test.go new file mode 100644 index 000000000..046e19f24 --- /dev/null +++ b/internal/cmd/volume/snapshot/describe/describe_test.go @@ -0,0 +1,211 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/iaas" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSnapshotId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + SnapshotId: testSnapshotId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetSnapshotRequest)) iaas.ApiGetSnapshotRequest { + request := testClient.GetSnapshot(testCtx, testProjectId, testRegion, testSnapshotId) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "snapshot id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiGetSnapshotRequest + }{ + { + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + snapshot *iaas.Snapshot + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty snapshot", + args: args{ + snapshot: &iaas.Snapshot{}, + }, + wantErr: false, + }, + { + name: "snapshot with values", + args: args{ + snapshot: &iaas.Snapshot{ + Id: utils.Ptr("snapshot-1"), + Name: utils.Ptr("test-snapshot"), + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.snapshot); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/volume/snapshot/list/list.go b/internal/cmd/volume/snapshot/list/list.go new file mode 100644 index 000000000..a2d312b26 --- /dev/null +++ b/internal/cmd/volume/snapshot/list/list.go @@ -0,0 +1,174 @@ +package list + +import ( + "context" + "fmt" + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" +) + +const ( + limitFlag = "limit" + labelSelectorFlag = "label-selector" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 + LabelSelector *string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all snapshots", + Long: "Lists all snapshots in a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all snapshots`, + "$ stackit volume snapshot list"), + examples.NewExample( + `List snapshots with a limit of 10`, + "$ stackit volume snapshot list --limit 10"), + examples.NewExample( + `List snapshots filtered by label`, + "$ stackit volume snapshot list --label-selector key1=value1"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list snapshots: %w", err) + } + + // Check if response is empty + if resp.Items == nil || len(*resp.Items) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + params.Printer.Info("No snapshots found for project %q\n", projectLabel) + return nil + } + + snapshots := *resp.Items + + // Apply limit if specified + if model.Limit != nil && int(*model.Limit) < len(snapshots) { + snapshots = snapshots[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, snapshots) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().String(labelSelectorFlag, "", "Filter snapshots by labels") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + labelSelector := flags.FlagToStringPointer(p, cmd, labelSelectorFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + LabelSelector: labelSelector, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListSnapshotsInProjectRequest { + req := apiClient.ListSnapshotsInProject(ctx, model.ProjectId, model.Region) + if model.LabelSelector != nil { + req = req.LabelSelector(*model.LabelSelector) + } + return req +} + +func outputResult(p *print.Printer, outputFormat string, snapshots []iaas.Snapshot) error { + if snapshots == nil { + return fmt.Errorf("list snapshots response is empty") + } + + return p.OutputResult(outputFormat, snapshots, func() error { + table := tables.NewTable() + table.SetHeader("ID", "NAME", "SIZE", "STATUS", "VOLUME ID", "LABELS", "CREATED AT", "UPDATED AT") + + for _, snapshot := range snapshots { + var labelsString string + if snapshot.Labels != nil { + var labels []string + for key, value := range *snapshot.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + labelsString = strings.Join(labels, "\n") + } + table.AddRow( + utils.PtrString(snapshot.Id), + utils.PtrString(snapshot.Name), + utils.PtrGigaByteSizeDefault(snapshot.Size, "n/a"), + utils.PtrString(snapshot.Status), + utils.PtrString(snapshot.VolumeId), + labelsString, + utils.ConvertTimePToDateTimeString(snapshot.CreatedAt), + utils.ConvertTimePToDateTimeString(snapshot.UpdatedAt), + ) + table.AddSeparator() + } + + p.Outputln(table.Render()) + return nil + }) +} diff --git a/internal/cmd/volume/snapshot/list/list_test.go b/internal/cmd/volume/snapshot/list/list_test.go new file mode 100644 index 000000000..ff2d86383 --- /dev/null +++ b/internal/cmd/volume/snapshot/list/list_test.go @@ -0,0 +1,229 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/iaas" +) + +const ( + testRegion = "eu01" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + limitFlag: "10", + labelSelectorFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + LabelSelector: utils.Ptr("key1=value1"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListSnapshotsInProjectRequest)) iaas.ApiListSnapshotsInProjectRequest { + request := testClient.ListSnapshotsInProject(testCtx, testProjectId, testRegion) + request = request.LabelSelector("key1=value1") + 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", + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.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, + }, + { + description: "only required flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, limitFlag) + delete(flagValues, labelSelectorFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Limit = nil + model.LabelSelector = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListSnapshotsInProjectRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "without label selector", + model: fixtureInputModel(func(model *inputModel) { + model.LabelSelector = nil + }), + expectedRequest: fixtureRequest(func(request *iaas.ApiListSnapshotsInProjectRequest) { + *request = testClient.ListSnapshotsInProject(testCtx, testProjectId, testRegion) + }), + }, + } + + 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) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + snapshots []iaas.Snapshot + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty snapshot in slice", + args: args{ + snapshots: []iaas.Snapshot{{}}, + }, + wantErr: false, + }, + { + name: "snapshots as argument", + args: args{ + snapshots: []iaas.Snapshot{ + { + Id: utils.Ptr("snapshot-1"), + }, + { + Id: utils.Ptr("snapshot-2"), + }, + }, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.snapshots); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/volume/snapshot/snapshot.go b/internal/cmd/volume/snapshot/snapshot.go new file mode 100644 index 000000000..09640dc9b --- /dev/null +++ b/internal/cmd/volume/snapshot/snapshot.go @@ -0,0 +1,34 @@ +package snapshot + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "snapshot", + Short: "Provides functionality for snapshots", + Long: "Provides functionality for snapshots.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) +} diff --git a/internal/cmd/volume/snapshot/update/update.go b/internal/cmd/volume/snapshot/update/update.go new file mode 100644 index 000000000..5b83aaeaf --- /dev/null +++ b/internal/cmd/volume/snapshot/update/update.go @@ -0,0 +1,135 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + snapshotIdArg = "SNAPSHOT_ID" + nameFlag = "name" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + SnapshotId string + Name *string + Labels map[string]string +} + +func NewCmd(params *types.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", snapshotIdArg), + Short: "Updates a snapshot", + Long: "Updates a snapshot by its ID.", + Args: args.SingleArg(snapshotIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update a snapshot name with ID "xxx"`, + "$ stackit volume snapshot update xxx --name my-new-name"), + examples.NewExample( + `Update a snapshot labels with ID "xxx"`, + "$ stackit volume snapshot update xxx --labels key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get snapshot name for label + snapshotLabel, err := iaasUtils.GetSnapshotName(ctx, apiClient, model.ProjectId, model.Region, model.SnapshotId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) + snapshotLabel = model.SnapshotId + } + + prompt := fmt.Sprintf("Are you sure you want to update snapshot %q?", snapshotLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update snapshot: %w", err) + } + + params.Printer.Outputf("Updated snapshot %q\n", snapshotLabel) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Name of the snapshot") + cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + snapshotId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) + if labels == nil { + labels = &map[string]string{} + } + + if name == nil && len(*labels) == 0 { + return nil, fmt.Errorf("either name or labels must be provided") + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SnapshotId: snapshotId, + Name: name, + Labels: *labels, + } + + p.DebugInputModel(model) + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateSnapshotRequest { + req := apiClient.UpdateSnapshot(ctx, model.ProjectId, model.Region, model.SnapshotId) + payload := iaas.NewUpdateSnapshotPayloadWithDefaults() + payload.Name = model.Name + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(model.Labels)) + + req = req.UpdateSnapshotPayload(*payload) + return req +} diff --git a/internal/cmd/volume/snapshot/update/update_test.go b/internal/cmd/volume/snapshot/update/update_test.go new file mode 100644 index 000000000..d5e159315 --- /dev/null +++ b/internal/cmd/volume/snapshot/update/update_test.go @@ -0,0 +1,207 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/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/iaas" +) + +const ( + testRegion = "eu01" + testName = "test-snapshot" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSnapshotId = uuid.NewString() + testLabels = map[string]string{"key1": "value1"} +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSnapshotId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + + nameFlag: testName, + labelsFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + SnapshotId: testSnapshotId, + Name: utils.Ptr(testName), + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateSnapshotRequest)) iaas.ApiUpdateSnapshotRequest { + request := testClient.UpdateSnapshot(testCtx, testProjectId, testRegion, testSnapshotId) + payload := iaas.NewUpdateSnapshotPayloadWithDefaults() + payload.Name = utils.Ptr(testName) + payload.Labels = utils.ConvertStringMapToInterfaceMap(utils.Ptr(testLabels)) + + request = request.UpdateSnapshotPayload(*payload) + 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, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "snapshot id invalid", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no update flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + delete(flagValues, labelsFlag) + }), + isValid: false, + }, + { + description: "only name flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Labels = make(map[string]string) + }), + }, + { + description: "only labels flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid) + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateSnapshotRequest + }{ + { + 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/volume/update/update.go b/internal/cmd/volume/update/update.go index f7afad38a..34fd7e31a 100644 --- a/internal/cmd/volume/update/update.go +++ b/internal/cmd/volume/update/update.go @@ -2,12 +2,13 @@ package update import ( "context" - "encoding/json" "fmt" - "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/examples" @@ -17,7 +18,6 @@ import ( "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) const ( @@ -36,7 +36,7 @@ type inputModel struct { Labels *map[string]string } -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("update %s", volumeIdArg), Short: "Updates a volume", @@ -58,29 +58,27 @@ func NewCmd(p *print.Printer) *cobra.Command { ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() - model, err := parseInput(p, cmd, args) + model, err := parseInput(params.Printer, cmd, args) if err != nil { return err } // Configure API client - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) if err != nil { return err } - volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.VolumeId) + volumeLabel, err := iaasUtils.GetVolumeName(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId) if err != nil { - p.Debug(print.ErrorLevel, "get volume name: %v", err) + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) volumeLabel = model.VolumeId } - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update volume %q?", volumeLabel) - err = p.PromptForConfirmation(prompt) - if err != nil { - return err - } + prompt := fmt.Sprintf("Are you sure you want to update volume %q?", volumeLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err } // Call API @@ -90,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command { return fmt.Errorf("update volume: %w", err) } - return outputResult(p, model.OutputFormat, volumeLabel, resp) + return outputResult(params.Printer, model.OutputFormat, volumeLabel, resp) }, } configureFlags(cmd) @@ -119,34 +117,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag), } - if p.IsVerbosityDebug() { - modelStr, err := print.BuildDebugStrFromInputModel(model) - if err != nil { - p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) - } else { - p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) - } - } - + p.DebugInputModel(model) return &model, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateVolumeRequest { - req := apiClient.UpdateVolume(ctx, model.ProjectId, model.VolumeId) - - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } + req := apiClient.UpdateVolume(ctx, model.ProjectId, model.Region, model.VolumeId) payload := iaas.UpdateVolumePayload{ Name: model.Name, Description: model.Description, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } return req.UpdateVolumePayload(payload) @@ -156,25 +137,8 @@ func outputResult(p *print.Printer, outputFormat, volumeLabel string, volume *ia if volume == nil { return fmt.Errorf("volume response is empty") } - switch outputFormat { - case print.JSONOutputFormat: - details, err := json.MarshalIndent(volume, "", " ") - if err != nil { - return fmt.Errorf("marshal volume: %w", err) - } - p.Outputln(string(details)) - - return nil - case print.YAMLOutputFormat: - details, err := yaml.MarshalWithOptions(volume, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) - if err != nil { - return fmt.Errorf("marshal volume: %w", err) - } - p.Outputln(string(details)) - - return nil - default: + return p.OutputResult(outputFormat, volume, func() error { p.Outputf("Updated volume %q.\n", volumeLabel) return nil - } + }) } diff --git a/internal/cmd/volume/update/update_test.go b/internal/cmd/volume/update/update_test.go index cdfed4017..da2d05b7e 100644 --- a/internal/cmd/volume/update/update_test.go +++ b/internal/cmd/volume/update/update_test.go @@ -4,16 +4,21 @@ import ( "context" "testing" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -var projectIdFlag = globalflags.ProjectIdFlag +const ( + testRegion = "eu01" +) type testCtxKey struct{} @@ -35,8 +40,10 @@ func fixtureArgValues(mods ...func(argValues []string)) []string { func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + nameFlag: "example-volume-name", - projectIdFlag: testProjectId, descriptionFlag: "example-volume-desc", labelFlag: "key=value", } @@ -50,6 +57,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, + Region: testRegion, Verbosity: globalflags.VerbosityDefault, }, Name: utils.Ptr("example-volume-name"), @@ -66,7 +74,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureRequest(mods ...func(request *iaas.ApiUpdateVolumeRequest)) iaas.ApiUpdateVolumeRequest { - request := testClient.UpdateVolume(testCtx, testProjectId, testVolumeId) + request := testClient.UpdateVolume(testCtx, testProjectId, testRegion, testVolumeId) request = request.UpdateVolumePayload(fixturePayload()) for _, mod := range mods { mod(&request) @@ -113,7 +121,7 @@ func TestParseInput(t *testing.T) { description: "project id missing", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) + delete(flagValues, globalflags.ProjectIdFlag) }), isValid: false, }, @@ -121,7 +129,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 1", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" + flagValues[globalflags.ProjectIdFlag] = "" }), isValid: false, }, @@ -129,7 +137,7 @@ func TestParseInput(t *testing.T) { description: "project id invalid 2", argValues: fixtureArgValues(), flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" }), isValid: false, }, @@ -176,7 +184,7 @@ func TestParseInput(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { p := print.NewPrinter() - cmd := NewCmd(p) + cmd := NewCmd(&types.CmdParams{Printer: p}) err := globalflags.Configure(cmd.Flags()) if err != nil { t.Fatalf("configure global flags: %v", err) @@ -280,7 +288,7 @@ func TestOutputResult(t *testing.T) { }, } p := print.NewPrinter() - p.Cmd = NewCmd(p) + p.Cmd = NewCmd(&types.CmdParams{Printer: p}) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := outputResult(p, tt.args.outputFormat, tt.args.volumeLabel, tt.args.volume); (err != nil) != tt.wantErr { diff --git a/internal/cmd/volume/volume.go b/internal/cmd/volume/volume.go index fe334cb6f..a6967a9ae 100644 --- a/internal/cmd/volume/volume.go +++ b/internal/cmd/volume/volume.go @@ -1,21 +1,23 @@ package volume import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/create" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/list" performanceclass "github.com/stackitcloud/stackit-cli/internal/cmd/volume/performance-class" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/resize" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/snapshot" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/update" "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/spf13/cobra" ) -func NewCmd(p *print.Printer) *cobra.Command { +func NewCmd(params *types.CmdParams) *cobra.Command { cmd := &cobra.Command{ Use: "volume", Short: "Provides functionality for volumes", @@ -23,16 +25,18 @@ func NewCmd(p *print.Printer) *cobra.Command { Args: args.NoArgs, Run: utils.CmdHelp, } - addSubcommands(cmd, p) + addSubcommands(cmd, params) return cmd } -func addSubcommands(cmd *cobra.Command, p *print.Printer) { - cmd.AddCommand(create.NewCmd(p)) - cmd.AddCommand(delete.NewCmd(p)) - cmd.AddCommand(describe.NewCmd(p)) - cmd.AddCommand(list.NewCmd(p)) - cmd.AddCommand(update.NewCmd(p)) - cmd.AddCommand(resize.NewCmd(p)) - cmd.AddCommand(performanceclass.NewCmd(p)) +func addSubcommands(cmd *cobra.Command, params *types.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(resize.NewCmd(params)) + cmd.AddCommand(performanceclass.NewCmd(params)) + cmd.AddCommand(snapshot.NewCmd(params)) + cmd.AddCommand(backup.NewCmd(params)) } diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go index 89a39ac29..685a1737a 100644 --- a/internal/pkg/auth/auth.go +++ b/internal/pkg/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "net/http" "os" "strconv" "time" @@ -24,7 +25,7 @@ type tokenClaims struct { // // If the user was logged in and the user session expired, reauthorizeUserRoutine is called to reauthenticate the user again. // If the environment variable STACKIT_ACCESS_TOKEN is set this token is used instead. -func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print.Printer, _ bool) error) (authCfgOption sdkConfig.ConfigurationOption, err error) { +func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print.Printer, _ UserAuthConfig) error) (authCfgOption sdkConfig.ConfigurationOption, err error) { // Get access token from env and use this if present accessToken := os.Getenv(envAccessTokenName) if accessToken != "" { @@ -69,7 +70,10 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print case AUTH_FLOW_USER_TOKEN: p.Debug(print.DebugLevel, "authenticating using user token") if userSessionExpired { - err = reauthorizeUserRoutine(p, true) + err = reauthorizeUserRoutine(p, UserAuthConfig{ + IsReauthentication: true, + Port: nil, + }) if err != nil { return nil, fmt.Errorf("user login: %w", err) } @@ -109,15 +113,23 @@ func GetAccessToken() (string, error) { func getStartingSessionExpiresAtUnix() (string, error) { sessionStart := time.Now() - sessionTimeLimitString := viper.GetString(config.SessionTimeLimitKey) - sessionTimeLimit, err := time.ParseDuration(sessionTimeLimitString) + sessionTimeLimit, err := getSessionExpiration() if err != nil { - return "", fmt.Errorf("parse session time limit \"%s\": %w", sessionTimeLimitString, err) + return "", err } sessionExpiresAt := sessionStart.Add(sessionTimeLimit) return strconv.FormatInt(sessionExpiresAt.Unix(), 10), nil } +func getSessionExpiration() (time.Duration, error) { + sessionTimeLimitString := viper.GetString(config.SessionTimeLimitKey) + duration, err := time.ParseDuration(sessionTimeLimitString) + if err != nil { + return 0, fmt.Errorf("parse session time limit \"%s\": %w", sessionTimeLimitString, err) + } + return duration, 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 @@ -132,3 +144,93 @@ func getEmailFromToken(token string) (string, error) { return claims.Email, nil } + +// GetValidAccessToken returns a valid access token for the current authentication flow. +// For user token flows, it refreshes the token if necessary. +// For service account flows, it returns the current access token. +func GetValidAccessToken(p *print.Printer) (string, error) { + flow, err := GetAuthFlow() + if err != nil { + return "", fmt.Errorf("get authentication flow: %w", err) + } + + // For service account flows, just return the current token + if flow == AUTH_FLOW_SERVICE_ACCOUNT_TOKEN || flow == AUTH_FLOW_SERVICE_ACCOUNT_KEY { + return GetAccessToken() + } + + if flow != AUTH_FLOW_USER_TOKEN { + return "", fmt.Errorf("unsupported authentication flow: %s", flow) + } + + // Load tokens from storage + authFields := map[authFieldKey]string{ + ACCESS_TOKEN: "", + REFRESH_TOKEN: "", + IDP_TOKEN_ENDPOINT: "", + } + err = GetAuthFieldMap(authFields) + if err != nil { + return "", fmt.Errorf("get tokens from auth storage: %w", err) + } + + accessToken := authFields[ACCESS_TOKEN] + refreshToken := authFields[REFRESH_TOKEN] + tokenEndpoint := authFields[IDP_TOKEN_ENDPOINT] + + if accessToken == "" { + return "", fmt.Errorf("access token not set") + } + if refreshToken == "" { + return "", fmt.Errorf("refresh token not set") + } + if tokenEndpoint == "" { + return "", fmt.Errorf("token endpoint not set") + } + + // Check if access token is expired + accessTokenExpired, err := TokenExpired(accessToken) + if err != nil { + return "", fmt.Errorf("check if access token has expired: %w", err) + } + if !accessTokenExpired { + // Token is still valid, return it + return accessToken, nil + } + + p.Debug(print.DebugLevel, "access token expired, refreshing...") + + // Create a temporary userTokenFlow to reuse the refresh logic + utf := &userTokenFlow{ + printer: p, + client: &http.Client{}, + authFlow: flow, + accessToken: accessToken, + refreshToken: refreshToken, + tokenEndpoint: tokenEndpoint, + } + + // Refresh the tokens + err = refreshTokens(utf) + if err != nil { + return "", fmt.Errorf("access token and refresh token expired: %w", err) + } + + // Return the new access token + return utf.accessToken, nil +} + +// EnsureIDPTokenEndpoint ensures that the `IDP_TOKEN_ENDPOINT` auth field is set. +// This field is by default only initialized for user accounts. Call this method to also +// initialize it for service accounts. +func EnsureIDPTokenEndpoint(p *print.Printer) error { + idpTokenEndpoint, err := GetAuthField(IDP_TOKEN_ENDPOINT) + if err != nil { + return fmt.Errorf("failed to check idp token endpoint configuration value: %w", err) + } + if idpTokenEndpoint == "" { + _, err := retrieveIDPWellKnownConfig(p) + return err + } + return nil +} diff --git a/internal/pkg/auth/auth_test.go b/internal/pkg/auth/auth_test.go index f7355f365..134850263 100644 --- a/internal/pkg/auth/auth_test.go +++ b/internal/pkg/auth/auth_test.go @@ -4,9 +4,12 @@ import ( "crypto/rand" "crypto/rsa" "crypto/x509" + "encoding/json" "encoding/pem" "fmt" "io" + "net/http" + "net/http/httptest" "strconv" "testing" "time" @@ -15,10 +18,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/uuid" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-sdk-go/core/clients" sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/zalando/go-keyring" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) const saKeyStrPattern = `{ @@ -188,7 +192,7 @@ func TestAuthenticationConfig(t *testing.T) { } reauthorizeUserCalled := false - reauthenticateUser := func(_ *print.Printer, _ bool) error { + reauthenticateUser := func(_ *print.Printer, _ UserAuthConfig) error { if reauthorizeUserCalled { t.Errorf("user reauthorized more than once") } @@ -235,58 +239,28 @@ func TestAuthenticationConfig(t *testing.T) { func TestInitKeyFlow(t *testing.T) { tests := []struct { - description string - accessTokenSet bool - refreshToken string - saKey string - privateKeySet bool - tokenEndpoint string - isValid bool + description string + saKey string + privateKeySet bool + isValid bool }{ { - description: "base", - accessTokenSet: true, - refreshToken: "refresh_token", - saKey: testServiceAccountKey, - privateKeySet: true, - tokenEndpoint: "token_url", - isValid: true, + description: "base", + saKey: testServiceAccountKey, + privateKeySet: true, + isValid: true, }, { - description: "invalid_service_account_key", - accessTokenSet: true, - refreshToken: "refresh_token", - saKey: "", - privateKeySet: true, - tokenEndpoint: "token_url", - isValid: false, + description: "invalid_service_account_key", + saKey: "", + privateKeySet: true, + isValid: false, }, { - description: "invalid_private_key", - accessTokenSet: true, - refreshToken: "refresh_token", - saKey: testServiceAccountKey, - privateKeySet: false, - tokenEndpoint: "token_url", - isValid: false, - }, - { - description: "invalid_access_token", - accessTokenSet: false, - refreshToken: "refresh_token", - saKey: testServiceAccountKey, - privateKeySet: true, - tokenEndpoint: "token_url", - isValid: false, - }, - { - description: "empty_refresh_token", - accessTokenSet: false, - refreshToken: "", - saKey: testServiceAccountKey, - privateKeySet: true, - tokenEndpoint: "token_url", - isValid: false, + description: "no_private_key_set", + saKey: testServiceAccountKey, + privateKeySet: false, + isValid: false, }, } @@ -297,13 +271,11 @@ func TestInitKeyFlow(t *testing.T) { 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) - } + 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() @@ -313,16 +285,42 @@ func TestInitKeyFlow(t *testing.T) { 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 + + // Mock server to avoid HTTP calls + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + resp := clients.TokenResponseBody{ + AccessToken: accessToken, + ExpiresIn: 3600, + TokenType: "Bearer", + } + jsonResp, err := json.Marshal(resp) + if err != nil { + t.Fatalf("Failed to marshal json: %v", err) + } + _, err = w.Write(jsonResp) + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } + })) + defer server.Close() + authFields[TOKEN_CUSTOM_ENDPOINT] = server.URL + err = SetAuthFieldMap(authFields) if err != nil { t.Fatalf("Failed to set in auth storage: %v", err) } keyFlowWithStorage, err := initKeyFlowWithStorage() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("Expected no error but error was returned: %v", err) + } + getAccessToken, err := keyFlowWithStorage.keyFlow.GetAccessToken() if !tt.isValid { if err == nil { t.Fatalf("Expected error but no error was returned") @@ -331,15 +329,8 @@ func TestInitKeyFlow(t *testing.T) { 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()) + if !cmp.Equal(accessToken, getAccessToken) { + t.Errorf("The returned result is wrong. Expected %+v, got %+v", accessToken, getAccessToken) } } }) diff --git a/internal/pkg/auth/exchange.go b/internal/pkg/auth/exchange.go new file mode 100644 index 000000000..0c005d237 --- /dev/null +++ b/internal/pkg/auth/exchange.go @@ -0,0 +1,90 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +func ExchangeToken(ctx context.Context, idpClient *http.Client, accessToken, resource string) (string, error) { + tokenEndpoint, err := GetAuthField(IDP_TOKEN_ENDPOINT) + if err != nil { + return "", fmt.Errorf("get idp token endpoint: %w", err) + } + + req, err := buildRequestToExchangeTokens(ctx, tokenEndpoint, accessToken, resource) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + resp, err := idpClient.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) + } + }() + + clusterToken, err := parseTokenExchangeResponse(resp) + if err != nil { + return "", fmt.Errorf("parse API response: %w", err) + } + return clusterToken, nil +} + +func buildRequestToExchangeTokens(ctx context.Context, tokenEndpoint, accessToken, resource string) (*http.Request, error) { + idpClientID, err := getIDPClientID() + if err != nil { + return nil, err + } + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + form.Set("client_id", idpClientID) + form.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") + form.Set("requested_token_type", "urn:ietf:params:oauth:token-type:id_token") + form.Set("scope", "openid profile email groups") + form.Set("subject_token", accessToken) + form.Set("resource", resource) + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + tokenEndpoint, + strings.NewReader(form.Encode()), + ) + if err != nil { + return nil, fmt.Errorf("build exchange request: %w", err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + return req, nil +} + +func parseTokenExchangeResponse(resp *http.Response) (accessToken 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"` + }{} + 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") + } + return respContent.AccessToken, nil +} diff --git a/internal/pkg/auth/exchange_test.go b/internal/pkg/auth/exchange_test.go new file mode 100644 index 000000000..77d49ac3b --- /dev/null +++ b/internal/pkg/auth/exchange_test.go @@ -0,0 +1,187 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/zalando/go-keyring" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + +var testAccessToken = "access-token-test-" + uuid.NewString() +var testExchangedToken = "access-token-exchanged-" + uuid.NewString() +var testExchangeResource = "resource://for/token/exchange" + +func fixtureTokenExchangeRequest(tokenEndpoint string) *http.Request { + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange") + form.Set("client_id", "stackit-cli-0000-0000-000000000001") + form.Set("subject_token_type", "urn:ietf:params:oauth:token-type:access_token") + form.Set("requested_token_type", "urn:ietf:params:oauth:token-type:id_token") + form.Set("scope", "openid profile email groups") + form.Set("subject_token", testAccessToken) + form.Set("resource", testExchangeResource) + + req, _ := http.NewRequestWithContext( + testCtx, + http.MethodPost, + tokenEndpoint, + strings.NewReader(form.Encode()), + ) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + return req +} + +func fixtureTokenExchangeResponse() string { + type exchangeReponse struct { + AccessToken string `json:"access_token"` + IssuedTokeType string `json:"issued_token_type"` + TokenType string `json:"token_type"` + } + response, _ := json.Marshal(exchangeReponse{ + AccessToken: testExchangedToken, + IssuedTokeType: "urn:ietf:params:oauth:token-type:id_token", + TokenType: "Bearer", + }) + return string(response) +} + +func TestBuildTokenExchangeRequest(t *testing.T) { + expectedRequest := fixtureTokenExchangeRequest(testTokenEndpoint) + req, err := buildRequestToExchangeTokens(testCtx, testTokenEndpoint, testAccessToken, testExchangeResource) + if err != nil { + t.Fatalf("func returned error: %s", err) + } + // directly using cmp.Diff is not possible, so dump the requests first + expected, err := httputil.DumpRequest(expectedRequest, true) + if err != nil { + t.Fatalf("fail to dump expected: %s", err) + } + actual, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatalf("fail to dump actual: %s", err) + } + diff := cmp.Diff(actual, expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} + +func TestParseTokenExchangeResponse(t *testing.T) { + response := fixtureTokenExchangeResponse() + + tests := []struct { + description string + response string + status int + expectError bool + }{ + { + description: "valid response", + response: response, + status: http.StatusOK, + }, + { + description: "error status", + response: response, // valid response to make sure the status code is checked + status: http.StatusForbidden, + expectError: true, + }, + { + description: "error content", + response: "{}", + status: http.StatusOK, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + w := httptest.NewRecorder() + w.WriteHeader(tt.status) + _, _ = w.WriteString(tt.response) + resp := w.Result() + + defer func() { + tempErr := resp.Body.Close() + if tempErr != nil { + t.Fatalf("failed to close response body: %v", tempErr) + } + }() + accessToken, err := parseTokenExchangeResponse(resp) + if tt.expectError { + if err == nil { + t.Fatal("expected error got nil") + } + } else { + if err != nil { + t.Fatalf("func returned error: %s", err) + } + diff := cmp.Diff(accessToken, testExchangedToken) + if diff != "" { + t.Fatalf("Token does not match: %s", diff) + } + } + }) + } +} + +func TestExchangeToken(t *testing.T) { + var request *http.Request + response := fixtureTokenExchangeResponse() + + handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // only compare body as the headers will differ + expected, err := io.ReadAll(request.Body) + if err != nil { + t.Errorf("fail to dump expected: %s", err) + } + actual, err := io.ReadAll(req.Body) + if err != nil { + t.Errorf("fail to dump actual: %s", err) + } + diff := cmp.Diff(actual, expected) + if diff != "" { + w.WriteHeader(http.StatusBadRequest) + t.Errorf("request mismatch: %v", diff) + return + } + + w.Header().Set("Content-Type", "application/json") + _, err = w.Write([]byte(response)) + if err != nil { + t.Errorf("Failed to write response: %v", err) + } + }) + server := httptest.NewServer(handler) + defer server.Close() + + request = fixtureTokenExchangeRequest(server.URL) + // use mock keyring to inject the token endpoint URL + keyring.MockInit() + err := SetAuthField(IDP_TOKEN_ENDPOINT, server.URL) + if err != nil { + t.Errorf("failed to inject idp token endpoint: %s", err) + } + + idToken, err := ExchangeToken(testCtx, server.Client(), testAccessToken, testExchangeResource) + if err != nil { + t.Fatalf("func returned error: %s", err) + } + diff := cmp.Diff(idToken, testExchangedToken) + if diff != "" { + t.Fatalf("Exchanged token does not match: %s", diff) + } +} diff --git a/internal/pkg/auth/service_account.go b/internal/pkg/auth/service_account.go index 1f1b01729..8aecaf0b4 100644 --- a/internal/pkg/auth/service_account.go +++ b/internal/pkg/auth/service_account.go @@ -14,7 +14,6 @@ import ( type keyFlowInterface interface { GetAccessToken() (string, error) GetConfig() clients.KeyFlowConfig - GetToken() clients.TokenResponseBody RoundTrip(*http.Request) (*http.Response, error) } @@ -32,7 +31,7 @@ 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 key flow, it fetches an access 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 // If disableWriting is set to true the credentials are not stored on disk (keyring, file). @@ -56,7 +55,6 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableW } authFields[ACCESS_TOKEN] = accessToken - authFields[REFRESH_TOKEN] = flow.GetToken().RefreshToken authFields[SERVICE_ACCOUNT_KEY] = string(saKeyBytes) authFields[PRIVATE_KEY] = flow.GetConfig().PrivateKey case tokenFlowInterface: @@ -82,6 +80,8 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableW return "", "", fmt.Errorf("compute session expiration timestamp: %w", err) } authFields[SESSION_EXPIRES_AT_UNIX] = sessionExpiresAtUnix + // clear idp token endpoint as it is not set by default for service accounts + authFields[IDP_TOKEN_ENDPOINT] = "" if !disableWriting { err = SetAuthFlow(authFlowType) @@ -100,8 +100,6 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableW // 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: "", @@ -110,12 +108,6 @@ func initKeyFlowWithStorage() (*keyFlowWithStorage, error) { 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) @@ -134,10 +126,6 @@ func initKeyFlowWithStorage() (*keyFlowWithStorage, error) { 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{ @@ -146,21 +134,26 @@ func initKeyFlowWithStorage() (*keyFlowWithStorage, error) { return keyFlowWithStorage, nil } -// The keyFlowWithStorage Roundtrip executes the keyFlow roundtrip and then stores the access and refresh tokens +// The keyFlowWithStorage Roundtrip executes the keyFlow roundtrip and then stores the access token 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 + accessToken, getTokenErr := kf.keyFlow.GetAccessToken() + if getTokenErr != nil { + return nil, fmt.Errorf("get access token: %w", getTokenErr) + } + tokenValues := map[authFieldKey]string{ - ACCESS_TOKEN: accessToken, - REFRESH_TOKEN: refreshToken, + ACCESS_TOKEN: accessToken, } storageErr := SetAuthFieldMap(tokenValues) if storageErr != nil { - return nil, fmt.Errorf("set access and refresh token in the storage: %w", err) + // If the request was successful, but storing the token failed we still return the response and a nil error + if err == nil { + return resp, nil + } + return nil, fmt.Errorf("set access token in the storage: %w", err) } return resp, err diff --git a/internal/pkg/auth/storage.go b/internal/pkg/auth/storage.go index 5e857f6a7..686a0f677 100644 --- a/internal/pkg/auth/storage.go +++ b/internal/pkg/auth/storage.go @@ -27,16 +27,18 @@ const ( ) 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" - IDP_TOKEN_ENDPOINT authFieldKey = "idp_token_endpoint" //nolint:gosec // linter false positive + 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" + IDP_TOKEN_ENDPOINT authFieldKey = "idp_token_endpoint" //nolint:gosec // linter false positive + CACHE_ENCRYPTION_KEY authFieldKey = "cache_encryption_key" + CACHE_ENCRYPTION_KEY_AGE authFieldKey = "cache_encryption_key_age" ) const ( @@ -59,6 +61,8 @@ var authFieldKeys = []authFieldKey{ TOKEN_CUSTOM_ENDPOINT, IDP_TOKEN_ENDPOINT, authFlowType, + CACHE_ENCRYPTION_KEY, + CACHE_ENCRYPTION_KEY_AGE, } // All fields that are set when a user logs in diff --git a/internal/pkg/auth/storage_test.go b/internal/pkg/auth/storage_test.go index 37eeee33e..d7320917e 100644 --- a/internal/pkg/auth/storage_test.go +++ b/internal/pkg/auth/storage_test.go @@ -6,8 +6,9 @@ import ( "testing" "time" - "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/zalando/go-keyring" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) func TestSetGetAuthField(t *testing.T) { diff --git a/internal/pkg/auth/templates/login-successful.html b/internal/pkg/auth/templates/login-successful.html index d8519cad0..3e2d0a5ba 100644 --- a/internal/pkg/auth/templates/login-successful.html +++ b/internal/pkg/auth/templates/login-successful.html @@ -6,7 +6,7 @@ diff --git a/internal/pkg/auth/user_login.go b/internal/pkg/auth/user_login.go index 8ac94743e..822b7daff 100644 --- a/internal/pkg/auth/user_login.go +++ b/internal/pkg/auth/user_login.go @@ -16,9 +16,10 @@ import ( "strings" "time" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "golang.org/x/oauth2" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) @@ -45,29 +46,22 @@ type InputValues struct { Logo string } +type UserAuthConfig struct { + // IsReauthentication defines if an expired user session should be renewed + IsReauthentication bool + // Port defines which port should be used for the UserAuthFlow callback + Port *int +} + type apiClient interface { Do(req *http.Request) (*http.Response, error) } // AuthorizeUser implements the PKCE OAuth2 flow. -func AuthorizeUser(p *print.Printer, isReauthentication bool) error { - idpWellKnownConfigURL, err := getIDPWellKnownConfigURL() - if err != nil { - return fmt.Errorf("get IDP well-known configuration: %w", err) - } - if idpWellKnownConfigURL != defaultWellKnownConfig { - p.Warn("You are using a custom identity provider well-known configuration (%s) for authentication.\n", idpWellKnownConfigURL) - err := p.PromptForEnter("Press Enter to proceed with the login...") - if err != nil { - return err - } - } - - p.Debug(print.DebugLevel, "get IDP well-known configuration from %s", idpWellKnownConfigURL) - httpClient := &http.Client{} - idpWellKnownConfig, err := parseWellKnownConfiguration(httpClient, idpWellKnownConfigURL) +func AuthorizeUser(p *print.Printer, authConfig UserAuthConfig) error { + idpWellKnownConfig, err := retrieveIDPWellKnownConfig(p) if err != nil { - return fmt.Errorf("parse IDP well-known configuration: %w", err) + return err } idpClientID, err := getIDPClientID() @@ -82,7 +76,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { } } - if isReauthentication { + if authConfig.IsReauthentication { err := p.PromptForEnter("Your session has expired, press Enter to login again...") if err != nil { return err @@ -92,21 +86,38 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { var redirectURL string var listener net.Listener var listenerErr error + var ipv6Listener net.Listener + var ipv6ListenerErr error var port int - for i := range configuredPortRange { - port = defaultPort + i - portString := fmt.Sprintf(":%s", strconv.Itoa(port)) + startingPort := defaultPort + portRange := configuredPortRange + if authConfig.Port != nil { + startingPort = *authConfig.Port + portRange = 1 + } + for i := range portRange { + port = startingPort + i + ipv4addr := fmt.Sprintf("127.0.0.1:%d", port) + ipv6addr := fmt.Sprintf("[::1]:%d", port) p.Debug(print.DebugLevel, "trying to bind port %d for login redirect", port) - listener, listenerErr = net.Listen("tcp", portString) + ipv6Listener, ipv6ListenerErr = net.Listen("tcp6", ipv6addr) + if ipv6ListenerErr != nil { + continue + } + listener, listenerErr = net.Listen("tcp4", ipv4addr) if listenerErr == nil { + _ = ipv6Listener.Close() redirectURL = fmt.Sprintf("http://localhost:%d", port) p.Debug(print.DebugLevel, "bound port %d for login redirect", port) break } p.Debug(print.DebugLevel, "unable to bind port %d for login redirect: %s", port, listenerErr) } + if ipv6ListenerErr != nil { + return fmt.Errorf("unable to bind port for login redirect, tried from port %d to %d: %w", startingPort, port, ipv6ListenerErr) + } if listenerErr != nil { - return fmt.Errorf("unable to bind port for login redirect, tried from port %d to %d: %w", defaultPort, port, err) + return fmt.Errorf("unable to bind port for login redirect, tried from port %d to %d: %w", startingPort, port, listenerErr) } conf := &oauth2.Config{ @@ -121,8 +132,13 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { // Initialize the code verifier codeVerifier := oauth2.GenerateVerifier() + // Generate max age based on the session time limit + maxSessionDuration, err := getSessionExpiration() + if err != nil { + return err + } // Construct the authorization URL - authorizationURL := conf.AuthCodeURL("", oauth2.S256ChallengeOption(codeVerifier)) + authorizationURL := conf.AuthCodeURL("", oauth2.S256ChallengeOption(codeVerifier), oauth2.SetAuthURLParam("max_age", fmt.Sprintf("%d", int64(maxSessionDuration.Seconds())))) // Start a web server to listen on a callback URL mux := http.NewServeMux() @@ -244,6 +260,10 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error { return fmt.Errorf("open browser to URL %s: %w", authorizationURL, err) } + // Print the link + p.Info("Your browser has been opened to visit:\n\n") + p.Info("%s\n\n", authorizationURL) + // Start the blocking web server loop // It will exit when the handlers get fired and call server.Close() p.Debug(print.DebugLevel, "listening for response from authentication server on %s", redirectURL) @@ -323,7 +343,7 @@ func cleanup(server *http.Server) { func openBrowser(pageUrl string) error { var err error switch runtime.GOOS { - case "linux": + case "freebsd", "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 { @@ -343,48 +363,3 @@ func openBrowser(pageUrl string) error { } return nil } - -// parseWellKnownConfiguration gets the well-known OpenID configuration from the provided URL and returns it as a JSON -// the method also stores the IDP token endpoint in the authentication storage -func parseWellKnownConfiguration(httpClient apiClient, wellKnownConfigURL string) (wellKnownConfig *wellKnownConfig, err error) { - req, _ := http.NewRequest("GET", wellKnownConfigURL, http.NoBody) - res, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("make the request: %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 nil, fmt.Errorf("read response body: %w", err) - } - - err = json.Unmarshal(body, &wellKnownConfig) - if err != nil { - return nil, fmt.Errorf("unmarshal response: %w", err) - } - if wellKnownConfig == nil { - return nil, fmt.Errorf("nil well-known configuration response") - } - if wellKnownConfig.Issuer == "" { - return nil, fmt.Errorf("found no issuer") - } - if wellKnownConfig.AuthorizationEndpoint == "" { - return nil, fmt.Errorf("found no authorization endpoint") - } - if wellKnownConfig.TokenEndpoint == "" { - return nil, fmt.Errorf("found no token endpoint") - } - - err = SetAuthField(IDP_TOKEN_ENDPOINT, wellKnownConfig.TokenEndpoint) - if err != nil { - return nil, fmt.Errorf("set token endpoint in the authentication storage: %w", err) - } - return wellKnownConfig, err -} diff --git a/internal/pkg/auth/user_login_test.go b/internal/pkg/auth/user_login_test.go index 7b61a4af5..e6f4bf040 100644 --- a/internal/pkg/auth/user_login_test.go +++ b/internal/pkg/auth/user_login_test.go @@ -5,10 +5,6 @@ import ( "io" "net/http" "strings" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/zalando/go-keyring" ) type apiClientMocked struct { @@ -28,83 +24,3 @@ func (a *apiClientMocked) Do(_ *http.Request) (*http.Response, error) { Body: io.NopCloser(strings.NewReader(a.getResponse)), }, nil } - -func TestParseWellKnownConfig(t *testing.T) { - tests := []struct { - name string - getFails bool - getResponse string - isValid bool - expected *wellKnownConfig - }{ - { - name: "success", - getFails: false, - getResponse: `{"issuer":"issuer","authorization_endpoint":"auth","token_endpoint":"token"}`, - isValid: true, - expected: &wellKnownConfig{ - Issuer: "issuer", - AuthorizationEndpoint: "auth", - TokenEndpoint: "token", - }, - }, - { - name: "get_fails", - getFails: true, - getResponse: "", - isValid: false, - expected: nil, - }, - { - name: "empty_response", - getFails: true, - getResponse: "", - isValid: false, - expected: nil, - }, - { - name: "missing_issuer", - getFails: true, - getResponse: `{"authorization_endpoint":"auth","token_endpoint":"token"}`, - isValid: false, - expected: nil, - }, - { - name: "missing_authorization", - getFails: true, - getResponse: `{"issuer":"issuer","token_endpoint":"token"}`, - isValid: false, - expected: nil, - }, - { - name: "missing_token", - getFails: true, - getResponse: `{"issuer":"issuer","authorization_endpoint":"auth"}`, - isValid: false, - expected: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - keyring.MockInit() - - testClient := apiClientMocked{ - tt.getFails, - tt.getResponse, - } - - got, err := parseWellKnownConfiguration(&testClient, "") - - if tt.isValid && err != nil { - t.Fatalf("expected no error, got %v", err) - } - if !tt.isValid && err == nil { - t.Fatalf("expected error, got none") - } - - if tt.isValid && !cmp.Equal(*got, *tt.expected) { - t.Fatalf("expected %v, got %v", tt.expected, got) - } - }) - } -} diff --git a/internal/pkg/auth/user_token_flow.go b/internal/pkg/auth/user_token_flow.go index 8a49c6b45..2e12b59a4 100644 --- a/internal/pkg/auth/user_token_flow.go +++ b/internal/pkg/auth/user_token_flow.go @@ -6,15 +6,17 @@ import ( "io" "net/http" "net/url" + "strings" "time" "github.com/golang-jwt/jwt/v5" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) type userTokenFlow struct { printer *print.Printer - reauthorizeUserRoutine func(p *print.Printer, isReauthentication bool) error // Called if the user needs to login again + reauthorizeUserRoutine func(p *print.Printer, isReauthentication UserAuthConfig) error // Called if the user needs to login again client *http.Client authFlow AuthFlow accessToken string @@ -94,7 +96,12 @@ func loadVarsFromStorage(utf *userTokenFlow) error { } func reauthenticateUser(utf *userTokenFlow) error { - err := utf.reauthorizeUserRoutine(utf.printer, true) + err := utf.reauthorizeUserRoutine( + utf.printer, + UserAuthConfig{ + IsReauthentication: true, + }, + ) if err != nil { return fmt.Errorf("authenticate user: %w", err) } @@ -108,20 +115,28 @@ func reauthenticateUser(utf *userTokenFlow) error { return nil } -func TokenExpired(token string) (bool, error) { +func TokenExpirationTime(token string) (time.Time, 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) + return time.Time{}, 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) + return time.Time{}, fmt.Errorf("get expiration timestamp from access token: %w", err) + } else if expirationTimestampNumeric == nil { + return time.Time{}, nil + } + return expirationTimestampNumeric.Time, nil +} + +func TokenExpired(token string) (bool, error) { + expirationTimestamp, err := TokenExpirationTime(token) + if err != nil || expirationTimestamp.Equal(time.Time{}) { + return false, err } - expirationTimestamp := expirationTimestampNumeric.Time - now := time.Now() - return now.After(expirationTimestamp), nil + return time.Now().After(expirationTimestamp), nil } // Refresh access and refresh tokens using a valid refresh token @@ -164,20 +179,20 @@ func buildRequestToRefreshTokens(utf *userTokenFlow) (*http.Request, error) { return nil, err } + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("client_id", idpClientID) + form.Set("refresh_token", utf.refreshToken) + req, err := http.NewRequest( http.MethodPost, utf.tokenEndpoint, - http.NoBody, + strings.NewReader(form.Encode()), ) if err != nil { return nil, err } - reqQuery := url.Values{} - reqQuery.Set("grant_type", "refresh_token") - reqQuery.Set("client_id", idpClientID) - reqQuery.Set("refresh_token", utf.refreshToken) - reqQuery.Set("token_format", "jwt") - req.URL.RawQuery = reqQuery.Encode() + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") return req, nil } diff --git a/internal/pkg/auth/user_token_flow_test.go b/internal/pkg/auth/user_token_flow_test.go index 6aeac368f..d89015ec2 100644 --- a/internal/pkg/auth/user_token_flow_test.go +++ b/internal/pkg/auth/user_token_flow_test.go @@ -10,8 +10,9 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/zalando/go-keyring" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) const ( @@ -278,7 +279,7 @@ func TestRoundTrip(t *testing.T) { authorizeUserCalled: &authorizeUserCalled, tokensRefreshed: &tokensRefreshed, } - authorizeUserRoutine := func(_ *print.Printer, _ bool) error { + authorizeUserRoutine := func(_ *print.Printer, _ UserAuthConfig) error { return reauthorizeUser(authorizeUserContext) } @@ -381,3 +382,40 @@ func createTokens(accessTokenExpiresAt, refreshTokenExpiresAt time.Time) (access return accessToken, refreshToken, nil } + +func TestTokenExpired(t *testing.T) { + tests := []struct { + desc string + token string + expected bool + }{ + { + desc: "token without exp", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.e30.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c`, + expected: false, + }, + { + desc: "exp 0", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjB9.rIhVGrtR0B0gUYPZDnB6LZ_w7zckH_9qFZBWG4rCkRY`, + expected: true, + }, + { + desc: "exp 9007199254740991", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjIyNTc2MDkwNzExMTExMTExfQ.aStshPjoSKTIcBeESbLJWvbMVuw-XWInXcf1P7tiWaE`, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + actual, err := TokenExpired(tt.token) + if err != nil { + t.Fatalf("TokenExpired() error = %v", err) + } + + if actual != tt.expected { + t.Errorf("TokenExpired() = %v, want %v", actual, tt.expected) + } + }) + } +} diff --git a/internal/pkg/auth/utils.go b/internal/pkg/auth/utils.go index 4fa431262..faf24b687 100644 --- a/internal/pkg/auth/utils.go +++ b/internal/pkg/auth/utils.go @@ -1,10 +1,15 @@ package auth import ( + "encoding/json" "fmt" + "io" + "net/http" "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) @@ -39,3 +44,70 @@ func getIDPClientID() (string, error) { return idpClientID, nil } + +func retrieveIDPWellKnownConfig(p *print.Printer) (*wellKnownConfig, error) { + idpWellKnownConfigURL, err := getIDPWellKnownConfigURL() + if err != nil { + return nil, fmt.Errorf("get IDP well-known configuration: %w", err) + } + if idpWellKnownConfigURL != defaultWellKnownConfig { + p.Warn("You are using a custom identity provider well-known configuration (%s) for authentication.\n", idpWellKnownConfigURL) + err := p.PromptForEnter("Press Enter to proceed with the login...") + if err != nil { + return nil, err + } + } + + p.Debug(print.DebugLevel, "get IDP well-known configuration from %s", idpWellKnownConfigURL) + httpClient := &http.Client{} + idpWellKnownConfig, err := parseWellKnownConfiguration(httpClient, idpWellKnownConfigURL) + if err != nil { + return nil, fmt.Errorf("parse IDP well-known configuration: %w", err) + } + return idpWellKnownConfig, nil +} + +// parseWellKnownConfiguration gets the well-known OpenID configuration from the provided URL and returns it as a JSON +// the method also stores the IDP token endpoint in the authentication storage +func parseWellKnownConfiguration(httpClient apiClient, wellKnownConfigURL string) (wellKnownConfig *wellKnownConfig, err error) { + req, _ := http.NewRequest("GET", wellKnownConfigURL, http.NoBody) + res, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("make the request: %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 nil, fmt.Errorf("read response body: %w", err) + } + + err = json.Unmarshal(body, &wellKnownConfig) + if err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + if wellKnownConfig == nil { + return nil, fmt.Errorf("nil well-known configuration response") + } + if wellKnownConfig.Issuer == "" { + return nil, fmt.Errorf("found no issuer") + } + if wellKnownConfig.AuthorizationEndpoint == "" { + return nil, fmt.Errorf("found no authorization endpoint") + } + if wellKnownConfig.TokenEndpoint == "" { + return nil, fmt.Errorf("found no token endpoint") + } + + err = SetAuthField(IDP_TOKEN_ENDPOINT, wellKnownConfig.TokenEndpoint) + if err != nil { + return nil, fmt.Errorf("set token endpoint in the authentication storage: %w", err) + } + return wellKnownConfig, err +} diff --git a/internal/pkg/auth/utils_test.go b/internal/pkg/auth/utils_test.go index 8112257d6..6fd67fc75 100644 --- a/internal/pkg/auth/utils_test.go +++ b/internal/pkg/auth/utils_test.go @@ -3,7 +3,10 @@ package auth import ( "testing" + "github.com/google/go-cmp/cmp" "github.com/spf13/viper" + "github.com/zalando/go-keyring" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) @@ -118,3 +121,83 @@ func TestGetIDPClientID(t *testing.T) { }) } } + +func TestParseWellKnownConfig(t *testing.T) { + tests := []struct { + name string + getFails bool + getResponse string + isValid bool + expected *wellKnownConfig + }{ + { + name: "success", + getFails: false, + getResponse: `{"issuer":"issuer","authorization_endpoint":"auth","token_endpoint":"token"}`, + isValid: true, + expected: &wellKnownConfig{ + Issuer: "issuer", + AuthorizationEndpoint: "auth", + TokenEndpoint: "token", + }, + }, + { + name: "get_fails", + getFails: true, + getResponse: "", + isValid: false, + expected: nil, + }, + { + name: "empty_response", + getFails: true, + getResponse: "", + isValid: false, + expected: nil, + }, + { + name: "missing_issuer", + getFails: true, + getResponse: `{"authorization_endpoint":"auth","token_endpoint":"token"}`, + isValid: false, + expected: nil, + }, + { + name: "missing_authorization", + getFails: true, + getResponse: `{"issuer":"issuer","token_endpoint":"token"}`, + isValid: false, + expected: nil, + }, + { + name: "missing_token", + getFails: true, + getResponse: `{"issuer":"issuer","authorization_endpoint":"auth"}`, + isValid: false, + expected: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + keyring.MockInit() + + testClient := apiClientMocked{ + tt.getFails, + tt.getResponse, + } + + got, err := parseWellKnownConfiguration(&testClient, "") + + if tt.isValid && err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("expected error, got none") + } + + if tt.isValid && !cmp.Equal(*got, *tt.expected) { + t.Fatalf("expected %v, got %v", tt.expected, got) + } + }) + } +} diff --git a/internal/pkg/cache/cache.go b/internal/pkg/cache/cache.go index cf019ecb2..beaf87d12 100644 --- a/internal/pkg/cache/cache.go +++ b/internal/pkg/cache/cache.go @@ -1,26 +1,87 @@ package cache import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" "errors" "fmt" "os" "path/filepath" "regexp" + "strconv" + "time" + + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" ) var ( - cacheFolderPath string + cacheDirOverwrite string // for testing only + cacheFolderPath string + cacheEncryptionKey []byte identifierRegex = regexp.MustCompile("^[a-zA-Z0-9-]+$") ErrorInvalidCacheIdentifier = fmt.Errorf("invalid cache identifier") ) +const ( + cacheKeyMaxAge = 90 * 24 * time.Hour +) + func Init() error { - cacheDir, err := os.UserCacheDir() - if err != nil { - return fmt.Errorf("get user cache dir: %w", err) + var cacheDir string + if cacheDirOverwrite == "" { + var err error + cacheDir, err = os.UserCacheDir() + if err != nil { + return fmt.Errorf("get user cache dir: %w", err) + } + } else { + cacheDir = cacheDirOverwrite } + cacheFolderPath = filepath.Join(cacheDir, "stackit") + + // Encryption keys should only be used a limited number of times for aes-gcm. + // Thus, refresh the key periodically. This will invalidate all cached entries. + key, _ := auth.GetAuthField(auth.CACHE_ENCRYPTION_KEY) + age, _ := auth.GetAuthField(auth.CACHE_ENCRYPTION_KEY_AGE) + cacheEncryptionKey = nil + var keyAge time.Time + if age != "" { + ageSeconds, err := strconv.ParseInt(age, 10, 64) + if err == nil { + keyAge = time.Unix(ageSeconds, 0) + } + } + if key != "" && keyAge.Add(cacheKeyMaxAge).After(time.Now()) { + cacheEncryptionKey, _ = base64.StdEncoding.DecodeString(key) + // invalid key length + if len(cacheEncryptionKey) != 32 { + cacheEncryptionKey = nil + } + } + if len(cacheEncryptionKey) == 0 { + cacheEncryptionKey = make([]byte, 32) + _, err := rand.Read(cacheEncryptionKey) + if err != nil { + return fmt.Errorf("cache encryption key: %w", err) + } + key := base64.StdEncoding.EncodeToString(cacheEncryptionKey) + err = auth.SetAuthField(auth.CACHE_ENCRYPTION_KEY, key) + if err != nil { + return fmt.Errorf("save cache encryption key: %w", err) + } + err = auth.SetAuthField(auth.CACHE_ENCRYPTION_KEY_AGE, fmt.Sprint(time.Now().Unix())) + if err != nil { + return fmt.Errorf("save cache encryption key age: %w", err) + } + // cleanup old cache entries as they won't be readable anymore + if err := cleanupCache(); err != nil { + return err + } + } return nil } @@ -32,7 +93,21 @@ func GetObject(identifier string) ([]byte, error) { return nil, ErrorInvalidCacheIdentifier } - return os.ReadFile(filepath.Join(cacheFolderPath, identifier)) + data, err := os.ReadFile(filepath.Join(cacheFolderPath, identifier)) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(cacheEncryptionKey) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + return nil, err + } + + return aead.Open(nil, nil, data, nil) } func PutObject(identifier string, data []byte) error { @@ -48,7 +123,17 @@ func PutObject(identifier string, data []byte) error { return err } - return os.WriteFile(filepath.Join(cacheFolderPath, identifier), data, 0o600) + block, err := aes.NewCipher(cacheEncryptionKey) + if err != nil { + return err + } + aead, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + return err + } + encrypted := aead.Seal(nil, nil, data, nil) + + return os.WriteFile(filepath.Join(cacheFolderPath, identifier), encrypted, 0o600) } func DeleteObject(identifier string) error { @@ -71,3 +156,26 @@ func validateCacheFolderPath() error { } return nil } + +func cleanupCache() error { + if err := validateCacheFolderPath(); err != nil { + return err + } + + entries, err := os.ReadDir(cacheFolderPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + for _, entry := range entries { + name := entry.Name() + err := DeleteObject(name) + if err != nil && !errors.Is(err, ErrorInvalidCacheIdentifier) { + return err + } + } + return nil +} diff --git a/internal/pkg/cache/cache_test.go b/internal/pkg/cache/cache_test.go index cc68c6590..fe0c143cc 100644 --- a/internal/pkg/cache/cache_test.go +++ b/internal/pkg/cache/cache_test.go @@ -6,10 +6,21 @@ import ( "path/filepath" "testing" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" + + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" ) -func TestGetObject(t *testing.T) { +func overwriteCacheDir(t *testing.T) func() { + cacheDirOverwrite = t.TempDir() + return func() { + cacheDirOverwrite = "" + } +} + +func TestGetObjectErrors(t *testing.T) { + defer overwriteCacheDir(t)() if err := Init(); err != nil { t.Fatalf("cache init failed: %s", err) } @@ -17,25 +28,16 @@ func TestGetObject(t *testing.T) { tests := []struct { description string identifier string - expectFile bool expectedErr error }{ - { - description: "identifier exists", - identifier: "test-cache-get-exists", - expectFile: true, - expectedErr: nil, - }, { description: "identifier does not exist", identifier: "test-cache-get-not-exists", - expectFile: false, expectedErr: os.ErrNotExist, }, { description: "identifier is invalid", identifier: "in../../valid", - expectFile: false, expectedErr: ErrorInvalidCacheIdentifier, }, } @@ -44,17 +46,6 @@ func TestGetObject(t *testing.T) { t.Run(tt.description, func(t *testing.T) { id := tt.identifier + "-" + uuid.NewString() - // setup - if tt.expectFile { - err := os.MkdirAll(cacheFolderPath, 0o750) - if err != nil { - t.Fatalf("create cache folder: %s", err.Error()) - } - path := filepath.Join(cacheFolderPath, id) - if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil { - t.Fatalf("setup: WriteFile (%s) failed", path) - } - } // test file, err := GetObject(id) @@ -62,19 +53,14 @@ func TestGetObject(t *testing.T) { t.Fatalf("returned error (%q) does not match %q", err.Error(), tt.expectedErr.Error()) } - if tt.expectFile { - if len(file) < 1 { - t.Fatalf("expected a file but byte array is empty (len %d)", len(file)) - } - } else { - if len(file) > 0 { - t.Fatalf("didn't expect a file, but byte array is not empty (len %d)", len(file)) - } + if len(file) > 0 { + t.Fatalf("didn't expect a file, but byte array is not empty (len %d)", len(file)) } }) } } func TestPutObject(t *testing.T) { + defer overwriteCacheDir(t)() if err := Init(); err != nil { t.Fatalf("cache init failed: %s", err) } @@ -128,6 +114,10 @@ func TestPutObject(t *testing.T) { // setup if tt.existingFile { + err := os.MkdirAll(cacheFolderPath, 0o750) + if err != nil { + t.Fatalf("create cache folder: %s", err.Error()) + } if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil { t.Fatalf("setup: WriteFile (%s) failed", path) } @@ -149,6 +139,7 @@ func TestPutObject(t *testing.T) { } func TestDeleteObject(t *testing.T) { + defer overwriteCacheDir(t)() if err := Init(); err != nil { t.Fatalf("cache init failed: %s", err) } @@ -186,8 +177,11 @@ func TestDeleteObject(t *testing.T) { // setup if tt.existingFile { + if err := os.MkdirAll(cacheFolderPath, 0o700); err != nil { + t.Fatalf("setup: MkdirAll (%s) failed: %v", path, err) + } if err := os.WriteFile(path, []byte("dummy"), 0o600); err != nil { - t.Fatalf("setup: WriteFile (%s) failed", path) + t.Fatalf("setup: WriteFile (%s) failed: %v", path, err) } } // test @@ -205,3 +199,90 @@ func TestDeleteObject(t *testing.T) { }) } } + +func clearKeys(t *testing.T) { + t.Helper() + err := auth.DeleteAuthField(auth.CACHE_ENCRYPTION_KEY) + if err != nil { + t.Fatalf("delete cache encryption key: %v", err) + } + err = auth.DeleteAuthField(auth.CACHE_ENCRYPTION_KEY_AGE) + if err != nil { + t.Fatalf("delete cache encryption key age: %v", err) + } +} + +func TestWriteAndRead(t *testing.T) { + for _, tt := range []struct { + name string + clearKeys bool + }{ + { + name: "normal", + }, + { + name: "fresh keys", + clearKeys: true, + }, + } { + t.Run(tt.name, func(t *testing.T) { + defer overwriteCacheDir(t)() + if tt.clearKeys { + clearKeys(t) + } + if err := Init(); err != nil { + t.Fatalf("cache init failed: %s", err) + } + + id := "test-cycle-" + uuid.NewString() + data := []byte("test-data") + err := PutObject(id, data) + if err != nil { + t.Fatalf("putobject failed: %v", err) + } + + readData, err := GetObject(id) + if err != nil { + t.Fatalf("getobject failed: %v", err) + } + + diff := cmp.Diff(data, readData) + if diff != "" { + t.Fatalf("unexpected data diff: %v", diff) + } + }) + } +} + +func TestCacheCleanup(t *testing.T) { + defer overwriteCacheDir(t)() + if err := Init(); err != nil { + t.Fatalf("cache init failed: %s", err) + } + + id := "test-cycle-" + uuid.NewString() + data := []byte("test-data") + err := PutObject(id, data) + if err != nil { + t.Fatalf("putobject failed: %v", err) + } + + clearKeys(t) + + // initialize again to trigger cache cleanup + if err := Init(); err != nil { + t.Fatalf("cache init failed: %s", err) + } + + _, err = GetObject(id) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("getobject failed with unexpected error: %v", err) + } +} + +func TestInit(t *testing.T) { + // test that init without cache directory overwrite works + if err := Init(); err != nil { + t.Fatalf("cache init failed: %s", err) + } +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index da282c335..638207898 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -17,13 +17,16 @@ const ( RegionKey = "region" SessionTimeLimitKey = "session_time_limit" VerbosityKey = "verbosity" + AssumeYesKey = "assume_yes" IdentityProviderCustomWellKnownConfigurationKey = "identity_provider_custom_well_known_configuration" IdentityProviderCustomClientIdKey = "identity_provider_custom_client_id" AllowedUrlDomainKey = "allowed_url_domain" AuthorizationCustomEndpointKey = "authorization_custom_endpoint" + AlbCustomEndpoint = "alb_custom _endpoint" DNSCustomEndpointKey = "dns_custom_endpoint" + EdgeCustomEndpointKey = "edge_custom_endpoint" LoadBalancerCustomEndpointKey = "load_balancer_custom_endpoint" LogMeCustomEndpointKey = "logme_custom_endpoint" MariaDBCustomEndpointKey = "mariadb_custom_endpoint" @@ -36,22 +39,29 @@ const ( RedisCustomEndpointKey = "redis_custom_endpoint" ResourceManagerEndpointKey = "resource_manager_custom_endpoint" SecretsManagerCustomEndpointKey = "secrets_manager_custom_endpoint" + KMSCustomEndpointKey = "kms_custom_endpoint" ServiceAccountCustomEndpointKey = "service_account_custom_endpoint" ServiceEnablementCustomEndpointKey = "service_enablement_custom_endpoint" ServerBackupCustomEndpointKey = "serverbackup_custom_endpoint" ServerOsUpdateCustomEndpointKey = "serverosupdate_custom_endpoint" RunCommandCustomEndpointKey = "runcommand_custom_endpoint" + SfsCustomEndpointKey = "sfs_custom_endpoint" SKECustomEndpointKey = "ske_custom_endpoint" SQLServerFlexCustomEndpointKey = "sqlserverflex_custom_endpoint" IaaSCustomEndpointKey = "iaas_custom_endpoint" TokenCustomEndpointKey = "token_custom_endpoint" + GitCustomEndpointKey = "git_custom_endpoint" + CDNCustomEndpointKey = "cdn_custom_endpoint" + IntakeCustomEndpointKey = "intake_custom_endpoint" + LogsCustomEndpointKey = "logs_custom_endpoint" ProjectNameKey = "project_name" DefaultProfileName = "default" AsyncDefault = false + AssumeYesDefault = false RegionDefault = "eu01" - SessionTimeLimitDefault = "2h" + SessionTimeLimitDefault = "12h" AllowedUrlDomainDefault = "stackit.cloud" ) @@ -74,12 +84,14 @@ var ConfigKeys = []string{ RegionKey, SessionTimeLimitKey, VerbosityKey, + AssumeYesKey, IdentityProviderCustomWellKnownConfigurationKey, IdentityProviderCustomClientIdKey, AllowedUrlDomainKey, DNSCustomEndpointKey, + EdgeCustomEndpointKey, LoadBalancerCustomEndpointKey, LogMeCustomEndpointKey, MariaDBCustomEndpointKey, @@ -94,6 +106,7 @@ var ConfigKeys = []string{ RedisCustomEndpointKey, ResourceManagerEndpointKey, SecretsManagerCustomEndpointKey, + KMSCustomEndpointKey, ServiceAccountCustomEndpointKey, ServiceEnablementCustomEndpointKey, ServerBackupCustomEndpointKey, @@ -103,6 +116,11 @@ var ConfigKeys = []string{ SQLServerFlexCustomEndpointKey, IaaSCustomEndpointKey, TokenCustomEndpointKey, + GitCustomEndpointKey, + IntakeCustomEndpointKey, + AlbCustomEndpoint, + LogsCustomEndpointKey, + CDNCustomEndpointKey, } var defaultConfigFolderPath string @@ -170,6 +188,7 @@ func setConfigDefaults() { viper.SetDefault(IdentityProviderCustomClientIdKey, "") viper.SetDefault(AllowedUrlDomainKey, AllowedUrlDomainDefault) viper.SetDefault(DNSCustomEndpointKey, "") + viper.SetDefault(EdgeCustomEndpointKey, "") viper.SetDefault(ObservabilityCustomEndpointKey, "") viper.SetDefault(AuthorizationCustomEndpointKey, "") viper.SetDefault(MongoDBFlexCustomEndpointKey, "") @@ -178,6 +197,7 @@ func setConfigDefaults() { viper.SetDefault(PostgresFlexCustomEndpointKey, "") viper.SetDefault(ResourceManagerEndpointKey, "") viper.SetDefault(SecretsManagerCustomEndpointKey, "") + viper.SetDefault(KMSCustomEndpointKey, "") viper.SetDefault(ServiceAccountCustomEndpointKey, "") viper.SetDefault(ServiceEnablementCustomEndpointKey, "") viper.SetDefault(ServerBackupCustomEndpointKey, "") @@ -187,6 +207,11 @@ func setConfigDefaults() { viper.SetDefault(SQLServerFlexCustomEndpointKey, "") viper.SetDefault(IaaSCustomEndpointKey, "") viper.SetDefault(TokenCustomEndpointKey, "") + viper.SetDefault(GitCustomEndpointKey, "") + viper.SetDefault(IntakeCustomEndpointKey, "") + viper.SetDefault(AlbCustomEndpoint, "") + viper.SetDefault(LogsCustomEndpointKey, "") + viper.SetDefault(CDNCustomEndpointKey, "") } func getConfigFilePath(configFolder string) string { diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go index db47ce5d3..83d48be2a 100644 --- a/internal/pkg/config/profiles.go +++ b/internal/pkg/config/profiles.go @@ -7,6 +7,8 @@ import ( "path/filepath" "regexp" + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/fileutils" "github.com/stackitcloud/stackit-cli/internal/pkg/print" @@ -79,8 +81,8 @@ func GetProfileFromEnv() (string, bool) { // CreateProfile creates a new profile. // If emptyProfile is true, it creates an empty profile. Otherwise, copies the config from the current profile to the new profile. // If setProfile is true, it sets the new profile as the active profile. -// If the profile already exists, it returns an error. -func CreateProfile(p *print.Printer, profile string, setProfile, emptyProfile bool) error { +// If the profile already exists and ignoreExisting is false, it returns an error. +func CreateProfile(p *print.Printer, profile string, setProfile, ignoreExisting, emptyProfile bool) error { err := ValidateProfile(profile) if err != nil { return fmt.Errorf("validate profile: %w", err) @@ -98,6 +100,15 @@ func CreateProfile(p *print.Printer, profile string, setProfile, emptyProfile bo // Error if the profile already exists _, err = os.Stat(configFolderPath) if err == nil { + if ignoreExisting { + if setProfile { + err = SetProfile(p, profile) + if err != nil { + return fmt.Errorf("set profile: %w", err) + } + } + return nil + } return fmt.Errorf("profile %q already exists", profile) } @@ -425,7 +436,13 @@ func ExportProfile(p *print.Printer, profile, exportPath string) error { return &errors.FileAlreadyExistsError{Filename: exportPath} } - err = fileutils.CopyFile(configFile, exportPath) + _, err = os.Stat(configFile) + if os.IsNotExist(err) { + // viper.SafeWriteConfigAs would not overwrite the target, so we use WriteConfigAs for the same behavior as CopyFile + err = viper.WriteConfigAs(exportPath) + } else { + err = fileutils.CopyFile(configFile, exportPath) + } if err != nil { return fmt.Errorf("export config file to %q: %w", exportPath, err) } diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go index 327c9dcf8..0039dc024 100644 --- a/internal/pkg/config/profiles_test.go +++ b/internal/pkg/config/profiles_test.go @@ -210,7 +210,7 @@ func TestExportProfile(t *testing.T) { // Create prerequisite profile p := print.NewPrinter() profileName := "export-profile-test" - err = CreateProfile(p, profileName, true, false) + err = CreateProfile(p, profileName, true, false, false) if err != nil { t.Fatalf("could not create prerequisite profile, %v", err) } diff --git a/internal/pkg/config/template/test_profile.json b/internal/pkg/config/template/test_profile.json index ab56ce66b..ed2702e7e 100644 --- a/internal/pkg/config/template/test_profile.json +++ b/internal/pkg/config/template/test_profile.json @@ -3,6 +3,7 @@ "async": false, "authorization_custom_endpoint": "", "dns_custom_endpoint": "", + "edge_custom_endpoint": "", "iaas_custom_endpoint": "", "identity_provider_custom_client_id": "", "identity_provider_custom_well_known_configuration": "", @@ -25,9 +26,10 @@ "serverbackup_custom_endpoint": "", "service_account_custom_endpoint": "", "service_enablement_custom_endpoint": "", - "session_time_limit": "2h", + "session_time_limit": "12h", + "sfs_custom_endpoint": "", "ske_custom_endpoint": "", "sqlserverflex_custom_endpoint": "", "token_custom_endpoint": "", "verbosity": "info" -} \ No newline at end of file +} diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index 9c83fb4f1..d690259ea 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -1,10 +1,13 @@ package errors import ( + "encoding/json" + sysErrors "errors" "fmt" "strings" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" ) const ( @@ -18,6 +21,16 @@ You can configure it for all commands by running: or you can also set it through the environment variable [STACKIT_PROJECT_ID]` + MISSING_REGION = `the region is not currently set. + +It can be set on the command level by re-running your command with the --region flag. + +You can configure it for all commands by running: + + $ stackit config set --region xxx + +or you can also set it through the environment variable [STACKIT_REGION]` + 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.` @@ -178,6 +191,12 @@ To list all profiles, run: $ stackit config profile list` FILE_ALREADY_EXISTS = `file %q already exists in the export path. Delete the existing file or define a different export path` + + FLAG_MUST_BE_PROVIDED_WHEN_ANOTHER_FLAG_IS_SET = `The flag %[1]q must be provided when %[2]q is set` + + MULTIPLE_FLAGS_MUST_BE_PROVIDED_WHEN_ANOTHER_FLAG_IS_SET = `The flags %[1]v must be provided when one of the flags %[2]v is set` + + ONE_OF_THE_FLAGS_MUST_BE_PROVIDED_WHEN_ANOTHER_FLAG_IS_SET = `One of the flags %[1]v must be provided when %[2]q is set` ) type ServerNicAttachMissingNicIdError struct { @@ -234,6 +253,12 @@ func (e *ProjectIdError) Error() string { return MISSING_PROJECT_ID } +type RegionError struct{} + +func (e *RegionError) Error() string { + return MISSING_REGION +} + type EmptyUpdateError struct{} func (e *EmptyUpdateError) Error() string { @@ -499,3 +524,163 @@ type FileAlreadyExistsError struct { } func (e *FileAlreadyExistsError) Error() string { return fmt.Sprintf(FILE_ALREADY_EXISTS, e.Filename) } + +type DependingFlagIsMissing struct { + MissingFlag string + SetFlag string +} + +func (e *DependingFlagIsMissing) Error() string { + return fmt.Sprintf(FLAG_MUST_BE_PROVIDED_WHEN_ANOTHER_FLAG_IS_SET, fmt.Sprintf("--%s", e.MissingFlag), fmt.Sprintf("--%s", e.SetFlag)) +} + +type MultipleFlagsAreMissing struct { + MissingFlags []string + SetFlags []string +} + +func (e *MultipleFlagsAreMissing) Error() string { + return fmt.Sprintf(MULTIPLE_FLAGS_MUST_BE_PROVIDED_WHEN_ANOTHER_FLAG_IS_SET, e.MissingFlags, e.SetFlags) +} + +type OneOfFlagsIsMissing struct { + MissingFlags []string + SetFlag string +} + +func (e *OneOfFlagsIsMissing) Error() string { + return fmt.Sprintf(ONE_OF_THE_FLAGS_MUST_BE_PROVIDED_WHEN_ANOTHER_FLAG_IS_SET, e.MissingFlags, e.SetFlag) +} + +// ___FORMATTING_ERRORS_________________________________________________________ + +// InvalidFormatError indicates that an unsupported format was provided. +type InvalidFormatError struct { + Format string // The invalid format that was provided +} + +func (e *InvalidFormatError) Error() string { + if e.Format != "" { + return fmt.Sprintf("unsupported format provided: %s", e.Format) + } + return "unsupported format provided" +} + +// NewInvalidFormatError creates a new InvalidFormatError with the provided format. +func NewInvalidFormatError(format string) *InvalidFormatError { + return &InvalidFormatError{ + Format: format, + } +} + +// ___BUILD_REQUEST_ERRORS______________________________________________________ +// BuildRequestError indicates that a request could not be built. +type BuildRequestError struct { + Reason string // Optional: specific reason why the request failed to build + Err error // Optional: underlying error +} + +func (e *BuildRequestError) Error() string { + if e.Reason != "" && e.Err != nil { + return fmt.Sprintf("could not build request (%s): %v", e.Reason, e.Err) + } + if e.Reason != "" { + return fmt.Sprintf("could not build request: %s", e.Reason) + } + if e.Err != nil { + return fmt.Sprintf("could not build request: %v", e.Err) + } + return "could not build request" +} + +func (e *BuildRequestError) Unwrap() error { + return e.Err +} + +// NewBuildRequestError creates a new BuildRequestError with optional reason and underlying error. +func NewBuildRequestError(reason string, err error) *BuildRequestError { + return &BuildRequestError{ + Reason: reason, + Err: err, + } +} + +// ___REQUESTS_ERRORS___________________________________________________________ +// RequestFailedError indicates that an API request failed. +// If the provided error is an OpenAPI error, the status code and message from the error body will be included in the error message. +type RequestFailedError struct { + Err error // Optional: underlying error +} + +func (e *RequestFailedError) Error() string { + var msg = "request failed" + + if e.Err != nil { + var oApiErr *oapierror.GenericOpenAPIError + if sysErrors.As(e.Err, &oApiErr) { + // Extract status code from OpenAPI error header if it exists + if oApiErr.StatusCode > 0 { + msg += fmt.Sprintf(" (%d)", oApiErr.StatusCode) + } + + // Try to extract message from OpenAPI error body + if bodyMsg := extractOpenApiMessageFromBody(oApiErr.Body); bodyMsg != "" { + msg += fmt.Sprintf(": %s", bodyMsg) + } else if trimmedBody := strings.TrimSpace(string(oApiErr.Body)); trimmedBody != "" { + msg += fmt.Sprintf(": %s", trimmedBody) + } else { + // Otherwise use the Go error + msg += fmt.Sprintf(": %v", e.Err) + } + } else { + // If this can't be cased into a OpenApi error use the Go error + msg += fmt.Sprintf(": %v", e.Err) + } + } + + return msg +} + +func (e *RequestFailedError) Unwrap() error { + return e.Err +} + +// NewRequestFailedError creates a new RequestFailedError with optional details. +func NewRequestFailedError(err error) *RequestFailedError { + return &RequestFailedError{ + Err: err, + } +} + +// ___HELPERS___________________________________________________________________ +// extractOpenApiMessageFromBody attempts to parse a JSON body and extract the "message" +// field. It returns an empty string if parsing fails or if no message is found. +func extractOpenApiMessageFromBody(body []byte) string { + trimmedBody := strings.TrimSpace(string(body)) + // Return early if empty. + if trimmedBody == "" { + return "" + } + + // Try to unmarshal as a structured error first + var errorBody struct { + Message string `json:"message"` + } + if err := json.Unmarshal(body, &errorBody); err == nil && errorBody.Message != "" { + if msg := strings.TrimSpace(errorBody.Message); msg != "" { + return msg + } + } + + // If that fails, try to unmarshal as a plain string + var plainBody string + if err := json.Unmarshal(body, &plainBody); err == nil && plainBody != "" { + if msg := strings.TrimSpace(plainBody); msg != "" { + return msg + } + return "" + } + + // All parsing attempts failed or yielded no message + return "" +} diff --git a/internal/pkg/errors/errors_test.go b/internal/pkg/errors/errors_test.go index d2942e87f..8a1c3d117 100644 --- a/internal/pkg/errors/errors_test.go +++ b/internal/pkg/errors/errors_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" ) var cmd *cobra.Command @@ -13,6 +14,13 @@ var service *cobra.Command var resource *cobra.Command var operation *cobra.Command +var ( + testErrorMessage = "test error message" + errStringErrTest = errors.New(testErrorMessage) + errOpenApi404 = &oapierror.GenericOpenAPIError{StatusCode: 404, Body: []byte(`{"message":"not found"}`)} + errOpenApi500 = &oapierror.GenericOpenAPIError{StatusCode: 500, Body: []byte(`invalid-json`)} +) + func setupCmd() { cmd = &cobra.Command{ Use: "stackit", @@ -686,3 +694,238 @@ func TestAppendUsageTip(t *testing.T) { }) } } + +func TestInvalidFormatError(t *testing.T) { + type args struct { + format string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + format: "", + }, + want: "unsupported format provided", + }, + { + name: "with format", + args: args{ + format: "yaml", + }, + want: "unsupported format provided: yaml", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&InvalidFormatError{Format: tt.args.format}).Error() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildRequestError(t *testing.T) { + type args struct { + reason string + err error + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + reason: "", + err: nil, + }, + want: "could not build request", + }, + { + name: "reason only", + args: args{ + reason: testErrorMessage, + err: nil, + }, + want: fmt.Sprintf("could not build request: %s", testErrorMessage), + }, + { + name: "error only", + args: args{ + reason: "", + err: errStringErrTest, + }, + want: fmt.Sprintf("could not build request: %s", testErrorMessage), + }, + { + name: "reason and error", + args: args{ + reason: testErrorMessage, + err: errStringErrTest, + }, + want: fmt.Sprintf("could not build request (%s): %s", testErrorMessage, testErrorMessage), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&BuildRequestError{Reason: tt.args.reason, Err: tt.args.err}).Error() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestRequestFailedError(t *testing.T) { + type args struct { + err error + } + tests := []struct { + name string + args args + want string + }{ + { + name: "nil underlying", + args: args{ + err: nil, + }, + want: "request failed", + }, + { + name: "non-openapi error", + args: args{ + err: errStringErrTest, + }, + want: fmt.Sprintf("request failed: %s", testErrorMessage), + }, + { + name: "openapi error with message", + args: args{ + err: errOpenApi404, + }, + want: "request failed (404): not found", + }, + { + name: "openapi error without message", + args: args{ + err: errOpenApi500, + }, + want: "request failed (500): invalid-json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&RequestFailedError{Err: tt.args.err}).Error() + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractMessageFromBody(t *testing.T) { + type args struct { + body []byte + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty body", + args: args{ + body: []byte(""), + }, + want: "", + }, + { + name: "invalid json", + args: args{ + body: []byte("not-json"), + }, + want: "", + }, + { + name: "missing message field", + args: args{ + body: []byte(`{"error":"oops"}`), + }, + want: "", + }, + { + name: "with message field", + args: args{ + body: []byte(`{"message":"the reason"}`), + }, + want: "the reason", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractOpenApiMessageFromBody(tt.args.body) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestConstructorsReturnExpected(t *testing.T) { + buildRequestError := NewBuildRequestError(testErrorMessage, errStringErrTest) + + tests := []struct { + name string + got any + want any + }{ + { + name: "InvalidFormat format", + got: NewInvalidFormatError("fmt").Format, + want: "fmt", + }, + { + name: "BuildRequestError error", + got: buildRequestError.Err, + want: errStringErrTest, + }, + { + name: "BuildRequestError reason", + got: buildRequestError.Reason, + want: testErrorMessage, + }, + { + name: "RequestFailed error", + got: NewRequestFailedError(errStringErrTest).Err, + want: errStringErrTest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wantErr, wantIsErr := tt.want.(error) + gotErr, gotIsErr := tt.got.(error) + if wantIsErr { + if !gotIsErr { + t.Fatalf("expected error but got %T", tt.got) + } + if !errors.Is(gotErr, wantErr) { + t.Errorf("got error %v, want %v", gotErr, wantErr) + } + return + } + + if tt.got != tt.want { + t.Errorf("got %v, want %v", tt.got, tt.want) + } + }) + } +} diff --git a/internal/pkg/flags/flag_to_value.go b/internal/pkg/flags/flag_to_value.go index 6385ba65a..b7d8194ed 100644 --- a/internal/pkg/flags/flag_to_value.go +++ b/internal/pkg/flags/flag_to_value.go @@ -5,6 +5,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) @@ -47,6 +48,20 @@ func FlagToStringSliceValue(p *print.Printer, cmd *cobra.Command, flag string) [ return nil } +// Returns the flag's value as a []string. +// Returns nil if flag is not set, if its value can not be converted to []string, or if the flag does not exist. +func FlagToStringArrayValue(p *print.Printer, cmd *cobra.Command, flag string) []string { + value, err := cmd.Flags().GetStringArray(flag) + if err != nil { + p.Debug(print.ErrorLevel, "convert flag to string array value: %v", err) + return nil + } + if !cmd.Flag(flag).Changed { + return nil + } + return value +} + // 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(p *print.Printer, cmd *cobra.Command, flag string) *map[string]string { //nolint:gocritic //convenient for setting the SDK payload @@ -61,6 +76,20 @@ func FlagToStringToStringPointer(p *print.Printer, cmd *cobra.Command, flag stri 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 int, or if the flag does not exist. +func FlagToIntPointer(p *print.Printer, cmd *cobra.Command, flag string) *int { + value, err := cmd.Flags().GetInt(flag) + if err != nil { + p.Debug(print.ErrorLevel, "convert flag to Uint64 pointer: %v", err) + 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(p *print.Printer, cmd *cobra.Command, flag string) *int64 { @@ -75,6 +104,20 @@ func FlagToInt64Pointer(p *print.Printer, cmd *cobra.Command, flag string) *int6 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 FlagToInt32Pointer(p *print.Printer, cmd *cobra.Command, flag string) *int32 { + value, err := cmd.Flags().GetInt32(flag) + if err != nil { + p.Debug(print.ErrorLevel, "convert flag to Int pointer: %v", err) + 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(p *print.Printer, cmd *cobra.Command, flag string) *string { diff --git a/internal/pkg/flags/flag_to_value_test.go b/internal/pkg/flags/flag_to_value_test.go new file mode 100644 index 000000000..6da23001a --- /dev/null +++ b/internal/pkg/flags/flag_to_value_test.go @@ -0,0 +1,187 @@ +package flags + +import ( + "fmt" + "reflect" + "testing" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func TestFlagToStringToStringPointer(t *testing.T) { + const flagName = "labels" + + tests := []struct { + name string + flagValue *string + want *map[string]string + }{ + { + name: "flag unset", + flagValue: nil, + want: nil, + }, + { + name: "flag set with single value", + flagValue: utils.Ptr("foo=bar"), + want: &map[string]string{ + "foo": "bar", + }, + }, + { + name: "flag set with multiple values", + flagValue: utils.Ptr("foo=bar,label1=value1,label2=value2"), + want: &map[string]string{ + "foo": "bar", + "label1": "value1", + "label2": "value2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + // create a new, simple test command with a string-to-string flag + cmd := func() *cobra.Command { + cmd := &cobra.Command{ + Use: "greet", + Short: "A simple greeting command", + Long: "A simple greeting command", + Run: func(_ *cobra.Command, _ []string) { + fmt.Println("Hello world") + }, + } + cmd.Flags().StringToString(flagName, nil, "Labels are key-value string pairs.") + return cmd + }() + + // set the flag value if a value use given, else consider the flag unset + if tt.flagValue != nil { + err := cmd.Flags().Set(flagName, *tt.flagValue) + if err != nil { + t.Error(err) + } + } + + if got := FlagToStringToStringPointer(p, cmd, flagName); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FlagToStringToStringPointer() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFlagToStringArrayValue(t *testing.T) { + const flagName = "geofencing" + tests := []struct { + name string + flagValues []string + want []string + }{ + { + name: "flag unset", + flagValues: nil, + want: nil, + }, + { + name: "single flag value", + flagValues: []string{ + "https://foo.example.com DE,CH", + }, + want: []string{ + "https://foo.example.com DE,CH", + }, + }, + { + name: "multiple flag value", + flagValues: []string{ + "https://foo.example.com DE,CH", + "https://bar.example.com AT", + }, + want: []string{ + "https://foo.example.com DE,CH", + "https://bar.example.com AT", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + cmd := func() *cobra.Command { + cmd := &cobra.Command{ + Use: "greet", + Short: "A simple greeting command", + Long: "A simple greeting command", + Run: func(_ *cobra.Command, _ []string) { + fmt.Println("Hello world") + }, + } + cmd.Flags().StringArray(flagName, []string{}, "url to multiple region codes, repeatable") + return cmd + }() + // set the flag value if a value use given, else consider the flag unset + if tt.flagValues != nil { + for _, val := range tt.flagValues { + err := cmd.Flags().Set(flagName, val) + if err != nil { + t.Error(err) + } + } + } + + if got := FlagToStringArrayValue(p, cmd, flagName); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FlagToStringArrayValue() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFlagToInt32Pointer(t *testing.T) { + const flagName = "limit" + tests := []struct { + name string + flagValue *string + want *int32 + }{ + { + name: "flag unset", + flagValue: nil, + want: nil, + }, + { + name: "flag value", + flagValue: utils.Ptr("42"), + want: utils.Ptr(int32(42)), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := print.NewPrinter() + cmd := func() *cobra.Command { + cmd := &cobra.Command{ + Use: "greet", + Short: "A simple greeting command", + Long: "A simple greeting command", + Run: func(_ *cobra.Command, _ []string) { + fmt.Println("Hello world") + }, + } + cmd.Flags().Int32(flagName, 0, "limit") + return cmd + }() + // set the flag value if a value use given, else consider the flag unset + if tt.flagValue != nil { + err := cmd.Flags().Set(flagName, *tt.flagValue) + if err != nil { + t.Error(err) + } + } + + if got := FlagToInt32Pointer(p, cmd, flagName); !reflect.DeepEqual(got, tt.want) { + t.Errorf("FlagToInt32Pointer() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/pkg/generic-client/generic_client.go b/internal/pkg/generic-client/generic_client.go new file mode 100644 index 000000000..ba5185dcb --- /dev/null +++ b/internal/pkg/generic-client/generic_client.go @@ -0,0 +1,51 @@ +package genericclient + +import ( + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type CreateApiClient[T any] func(opts ...sdkConfig.ConfigurationOption) (T, error) + +// ConfigureClientGeneric contains the generic code which needs to be executed in order to configure the api client. +func ConfigureClientGeneric[T any](p *print.Printer, cliVersion, customEndpoint string, useRegion bool, createApiClient CreateApiClient[T]) (T, error) { + // return value if an error happens + var zero T + authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) + if err != nil { + p.Debug(print.ErrorLevel, "configure authentication: %v", err) + return zero, &errors.AuthError{} + } + cfgOptions := []sdkConfig.ConfigurationOption{ + utils.UserAgentConfigOption(cliVersion), + authCfgOption, + } + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + if useRegion { + cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(viper.GetString(config.RegionKey))) + } + + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + + apiClient, err := createApiClient(cfgOptions...) + if err != nil { + p.Debug(print.ErrorLevel, "create new API client: %v", err) + return zero, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/globalflags/global_flags.go b/internal/pkg/globalflags/global_flags.go index 9f53ec4f7..63b51c6c8 100644 --- a/internal/pkg/globalflags/global_flags.go +++ b/internal/pkg/globalflags/global_flags.go @@ -60,6 +60,10 @@ func Configure(flagSet *pflag.FlagSet) error { } flagSet.BoolP(AssumeYesFlag, "y", false, "If set, skips all confirmation prompts") + err = viper.BindPFlag(config.AssumeYesKey, flagSet.Lookup(AssumeYesFlag)) + if err != nil { + return fmt.Errorf("bind --%s flag to config: %w", AssumeYesFlag, err) + } flagSet.Var(flags.EnumFlag(true, VerbosityDefault, verbosityFlagOptions...), VerbosityFlag, fmt.Sprintf("Verbosity of the CLI, one of %q", verbosityFlagOptions)) err = viper.BindPFlag(config.VerbosityKey, flagSet.Lookup(VerbosityFlag)) @@ -76,10 +80,10 @@ func Configure(flagSet *pflag.FlagSet) error { return nil } -func Parse(p *print.Printer, cmd *cobra.Command) *GlobalFlagModel { +func Parse(_ *print.Printer, _ *cobra.Command) *GlobalFlagModel { return &GlobalFlagModel{ Async: viper.GetBool(config.AsyncKey), - AssumeYes: flags.FlagToBoolValue(p, cmd, AssumeYesFlag), + AssumeYes: viper.GetBool(config.AssumeYesKey), OutputFormat: viper.GetString(config.OutputFormatKey), ProjectId: viper.GetString(config.ProjectIdKey), Region: viper.GetString(config.RegionKey), diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go index 60962ba7b..793c54bd3 100644 --- a/internal/pkg/print/debug.go +++ b/internal/pkg/print/debug.go @@ -16,11 +16,11 @@ import ( var defaultHTTPHeaders = []string{"Accept", "Content-Type", "Content-Length", "User-Agent", "Date", "Referrer-Policy", "Traceparent"} -// BuildDebugStrFromInputModel converts an input model to a user-friendly string representation. +// buildDebugStrFromInputModel converts an input model to a user-friendly string representation. // This function converts the input model to a map, removes empty values, and generates a string representation of the map. // The purpose of this function is to provide a more readable output than the default JSON representation. // It is particularly useful when outputting to the slog logger, as the JSON format with escaped quotes does not look good. -func BuildDebugStrFromInputModel(model any) (string, error) { +func buildDebugStrFromInputModel(model any) (string, error) { // Marshaling and Unmarshaling is the best way to convert the struct to a map modelBytes, err := json.Marshal(model) if err != nil { diff --git a/internal/pkg/print/debug_test.go b/internal/pkg/print/debug_test.go index 45ef90482..abc3dedeb 100644 --- a/internal/pkg/print/debug_test.go +++ b/internal/pkg/print/debug_test.go @@ -171,7 +171,7 @@ func TestBuildDebugStrFromInputModel(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { model := tt.model - actual, err := BuildDebugStrFromInputModel(model) + actual, err := buildDebugStrFromInputModel(model) if err != nil { if !tt.isValid { return diff --git a/internal/pkg/print/print.go b/internal/pkg/print/print.go index 63f48fe89..5f9bc398f 100644 --- a/internal/pkg/print/print.go +++ b/internal/pkg/print/print.go @@ -2,15 +2,17 @@ package print import ( "bufio" + "bytes" + "encoding/json" "errors" "fmt" - "syscall" - "log/slog" "os" "os/exec" "strings" + "github.com/goccy/go-yaml" + "github.com/fatih/color" "github.com/lmittmann/tint" "github.com/mattn/go-colorable" @@ -48,10 +50,11 @@ var ( type Printer struct { Cmd *cobra.Command + AssumeYes bool Verbosity Level } -// Creates a new printer, including setting up the default logger. +// NewPrinter creates a new printer, including setting up the default logger. func NewPrinter() *Printer { w := os.Stderr logger := slog.New( @@ -131,6 +134,10 @@ func (p *Printer) Error(msg string, args ...any) { // Returns nil only if the user (explicitly) answers positive. // Returns ErrAborted if the user answers negative. func (p *Printer) PromptForConfirmation(prompt string) error { + if p.AssumeYes { + p.Warn("Auto-confirming prompt: %q\n", prompt) + return nil + } question := fmt.Sprintf("%s [y/N] ", prompt) reader := bufio.NewReader(p.Cmd.InOrStdin()) for i := 0; i < 3; i++ { @@ -154,6 +161,10 @@ func (p *Printer) PromptForConfirmation(prompt string) error { // // Returns nil if the user presses Enter. func (p *Printer) PromptForEnter(prompt string) error { + if p.AssumeYes { + p.Warn("Auto-confirming prompt: %q", prompt) + return nil + } reader := bufio.NewReader(p.Cmd.InOrStdin()) p.Cmd.PrintErr(prompt) _, err := reader.ReadString('\n') @@ -169,11 +180,23 @@ func (p *Printer) PromptForEnter(prompt string) error { func (p *Printer) PromptForPassword(prompt string) (string, error) { p.Cmd.PrintErr(prompt) defer p.Outputln("") - bytePassword, err := term.ReadPassword(int(syscall.Stdin)) + + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + bytePassword, err := term.ReadPassword(fd) + if err != nil { + return "", fmt.Errorf("read password: %w", err) + } + return string(bytePassword), nil + } + + // Fallback for non-terminal environments + reader := bufio.NewReader(p.Cmd.InOrStdin()) + pw, err := reader.ReadString('\n') if err != nil { - return "", fmt.Errorf("read password: %w", err) + return "", fmt.Errorf("read password from non-terminal: %w", err) } - return string(bytePassword), nil + return pw[:len(pw)-1], nil // remove trailing newline } // Shows the content in the command's stdout using the "less" command @@ -228,3 +251,43 @@ func (p *Printer) IsVerbosityWarning() bool { func (p *Printer) IsVerbosityError() bool { return p.Verbosity == ErrorLevel } + +// DebugInputModel prints the given input model in case verbosity level is set to Debug, does nothing otherwise +func (p *Printer) DebugInputModel(model any) { + if p.IsVerbosityDebug() { + modelStr, err := buildDebugStrFromInputModel(model) + if err != nil { + p.Debug(ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(DebugLevel, "parsed input values: %s", modelStr) + } + } +} + +func (p *Printer) OutputResult(outputFormat string, output any, prettyOutputFunc func() error) error { + switch outputFormat { + case JSONOutputFormat: + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + err := encoder.Encode(output) + if err != nil { + return fmt.Errorf("marshal json: %w", err) + } + details := buffer.Bytes() + p.Outputln(string(details)) + + return nil + case YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(output, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal yaml: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + return prettyOutputFunc() + } +} diff --git a/internal/pkg/print/print_test.go b/internal/pkg/print/print_test.go index 5c68dc2cf..1867b9e03 100644 --- a/internal/pkg/print/print_test.go +++ b/internal/pkg/print/print_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log/slog" + "sync" "testing" "github.com/spf13/cobra" @@ -509,6 +510,7 @@ func TestPromptForConfirmation(t *testing.T) { verbosity Level isValid bool isAborted bool + assumeYes bool }{ // Note: Some of these inputs have normal spaces, others have tabs { @@ -647,6 +649,13 @@ func TestPromptForConfirmation(t *testing.T) { verbosity: DebugLevel, isValid: false, }, + { + description: "no input with assume yes", + input: "", + verbosity: DebugLevel, + isValid: true, + assumeYes: true, + }, } for _, tt := range tests { @@ -665,6 +674,7 @@ func TestPromptForConfirmation(t *testing.T) { p := &Printer{ Cmd: cmd, Verbosity: tt.verbosity, + AssumeYes: tt.assumeYes, } err = p.PromptForConfirmation("") @@ -860,3 +870,127 @@ func TestIsVerbosityError(t *testing.T) { }) } } + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + output any + prettyOutputFunc func() error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "output format is JSON", + args: args{ + outputFormat: JSONOutputFormat, + output: struct{}{}, + }, + }, + { + name: "output format is JSON and output is nil", + args: args{ + outputFormat: JSONOutputFormat, + output: nil, + }, + }, + { + name: "output format is YAML", + args: args{ + outputFormat: YAMLOutputFormat, + output: struct{}{}, + }, + }, + { + name: "output format is YAML and output is nil", + args: args{ + outputFormat: YAMLOutputFormat, + output: nil, + }, + }, + { + name: "should return error of pretty output func", + args: args{ + outputFormat: PrettyOutputFormat, + output: struct{}{}, + prettyOutputFunc: func() error { + return fmt.Errorf("dummy error") + }, + }, + wantErr: true, + }, + { + name: "success of pretty output func", + args: args{ + outputFormat: PrettyOutputFormat, + output: struct{}{}, + prettyOutputFunc: func() error { + return nil + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{} + p := &Printer{ + Cmd: cmd, + Verbosity: ErrorLevel, + } + + if err := p.OutputResult(tt.args.outputFormat, tt.args.output, tt.args.prettyOutputFunc); (err != nil) != tt.wantErr { + t.Errorf("OutputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestPromptForPassword(t *testing.T) { + tests := []struct { + description string + input string + }{ + { + description: "password", + input: "mypassword\n", + }, + { + description: "empty password", + input: "\n", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + r, w := io.Pipe() + defer func() { + r.Close() //nolint:errcheck // ignore error on close + w.Close() //nolint:errcheck // ignore error on close + }() + cmd.SetIn(r) + p := &Printer{ + Cmd: cmd, + Verbosity: ErrorLevel, + } + var pw string + var err error + var wg sync.WaitGroup + wg.Add(1) + go func() { + pw, err = p.PromptForPassword("Enter password: ") + wg.Done() + }() + w.Write([]byte(tt.input)) //nolint:errcheck // ignore error + wg.Wait() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + withoutNewline := tt.input[:len(tt.input)-1] + if pw != withoutNewline { + t.Fatalf("unexpected password: got %q, want %q", pw, withoutNewline) + } + }) + } +} diff --git a/internal/pkg/projectname/project_name.go b/internal/pkg/projectname/project_name.go index b50239b9f..b2c77117f 100644 --- a/internal/pkg/projectname/project_name.go +++ b/internal/pkg/projectname/project_name.go @@ -19,7 +19,7 @@ import ( // 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, p *print.Printer, cmd *cobra.Command) (string, error) { +func GetProjectName(ctx context.Context, p *print.Printer, cliVersion string, cmd *cobra.Command) (string, error) { // If we can use the project name from config, return it if useProjectNameFromConfig(p, cmd) { return viper.GetString(config.ProjectNameKey), nil @@ -30,7 +30,7 @@ func GetProjectName(ctx context.Context, p *print.Printer, cmd *cobra.Command) ( return "", fmt.Errorf("found empty project ID and name") } - apiClient, err := client.ConfigureClient(p) + apiClient, err := client.ConfigureClient(p, cliVersion) if err != nil { return "", fmt.Errorf("configure resource manager client: %w", err) } @@ -42,7 +42,7 @@ func GetProjectName(ctx context.Context, p *print.Printer, cmd *cobra.Command) ( // 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(p, cmd) || isProjectIdSetInEnvVar()) { + if !isProjectIdSetInFlags(p, cmd) && !isProjectIdSetInEnvVar() { viper.Set(config.ProjectNameKey, projectName) err = config.Write() if err != nil { @@ -61,10 +61,7 @@ func useProjectNameFromConfig(p *print.Printer, cmd *cobra.Command) bool { projectIdSetInFlags := isProjectIdSetInFlags(p, cmd) projectIdSetInEnv := isProjectIdSetInEnvVar() projectName := viper.GetString(config.ProjectNameKey) - projectNameSet := false - if projectName != "" { - projectNameSet = true - } + projectNameSet := projectName != "" return !projectIdSetInFlags && !projectIdSetInEnv && projectNameSet } @@ -73,10 +70,7 @@ func isProjectIdSetInFlags(p *print.Printer, cmd *cobra.Command) bool { // 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(p, cmd, globalflags.ProjectIdFlag) - projectIdSetInFlag := false - if projectIdFromFlag != nil { - projectIdSetInFlag = true - } + projectIdSetInFlag := projectIdFromFlag != nil return projectIdSetInFlag } diff --git a/internal/pkg/projectname/project_name_test.go b/internal/pkg/projectname/project_name_test.go index 25239c8f4..9b84509ff 100644 --- a/internal/pkg/projectname/project_name_test.go +++ b/internal/pkg/projectname/project_name_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" "github.com/stackitcloud/stackit-cli/internal/pkg/print" ) @@ -42,7 +43,7 @@ func TestGetProjectName(t *testing.T) { p := print.NewPrinter() cmd := &cobra.Command{} - projectName, err := GetProjectName(context.Background(), p, cmd) + projectName, err := GetProjectName(context.Background(), p, "0.0.0-dummy", cmd) if err != nil { if tt.isValid { t.Fatalf("unexpected error: %v", err) diff --git a/internal/pkg/services/alb/client/client.go b/internal/pkg/services/alb/client/client.go index c29f9eddb..1de12c654 100644 --- a/internal/pkg/services/alb/client/client.go +++ b/internal/pkg/services/alb/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/alb" ) -func ConfigureClient(p *print.Printer) (*alb.APIClient, error) { - var err error - var apiClient *alb.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.IaaSCustomEndpointKey) - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } else { - cfgOptions = append(cfgOptions, authCfgOption) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = alb.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*alb.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.AlbCustomEndpoint), true, genericclient.CreateApiClient[*alb.APIClient](alb.NewAPIClient)) } diff --git a/internal/pkg/services/authorization/client/client.go b/internal/pkg/services/authorization/client/client.go index 19c13d663..8646a8120 100644 --- a/internal/pkg/services/authorization/client/client.go +++ b/internal/pkg/services/authorization/client/client.go @@ -1,45 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/authorization" ) -func ConfigureClient(p *print.Printer) (*authorization.APIClient, error) { - var err error - var apiClient *authorization.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.AuthorizationCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = authorization.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*authorization.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.AuthorizationCustomEndpointKey), false, genericclient.CreateApiClient[*authorization.APIClient](authorization.NewAPIClient)) } diff --git a/internal/pkg/services/cdn/client/client.go b/internal/pkg/services/cdn/client/client.go new file mode 100644 index 000000000..844f077ef --- /dev/null +++ b/internal/pkg/services/cdn/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-sdk-go/services/cdn" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*cdn.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.CDNCustomEndpointKey), true, cdn.NewAPIClient) +} diff --git a/internal/pkg/services/cdn/utils/utils.go b/internal/pkg/services/cdn/utils/utils.go new file mode 100644 index 000000000..f9b903420 --- /dev/null +++ b/internal/pkg/services/cdn/utils/utils.go @@ -0,0 +1,40 @@ +package utils + +import ( + "strings" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +func ParseGeofencing(p *print.Printer, geofencingInput []string) *map[string][]string { //nolint:gocritic // convenient for setting the SDK payload + geofencing := make(map[string][]string) + for _, in := range geofencingInput { + firstSpace := strings.IndexRune(in, ' ') + if firstSpace == -1 { + p.Debug(print.ErrorLevel, "invalid geofencing entry (no space found): %q", in) + continue + } + urlPart := in[:firstSpace] + countriesPart := in[firstSpace+1:] + geofencing[urlPart] = nil + countries := strings.Split(countriesPart, ",") + for _, country := range countries { + country = strings.TrimSpace(country) + geofencing[urlPart] = append(geofencing[urlPart], country) + } + } + return &geofencing +} + +func ParseOriginRequestHeaders(p *print.Printer, originRequestHeadersInput []string) *map[string]string { //nolint:gocritic // convenient for setting the SDK payload + originRequestHeaders := make(map[string]string) + for _, in := range originRequestHeadersInput { + parts := strings.Split(in, ":") + if len(parts) != 2 { + p.Debug(print.ErrorLevel, "invalid origin request header entry (no colon found): %q", in) + continue + } + originRequestHeaders[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + return &originRequestHeaders +} diff --git a/internal/pkg/services/cdn/utils/utils_test.go b/internal/pkg/services/cdn/utils/utils_test.go new file mode 100644 index 000000000..9de52e3c4 --- /dev/null +++ b/internal/pkg/services/cdn/utils/utils_test.go @@ -0,0 +1,94 @@ +package utils + +import ( + "reflect" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +func TestParseGeofencing(t *testing.T) { + tests := []struct { + name string + input []string + want map[string][]string + }{ + { + name: "empty input", + input: nil, + want: map[string][]string{}, + }, + { + name: "single entry", + input: []string{ + "https://example.com US,CA,MX", + }, + want: map[string][]string{ + "https://example.com": {"US", "CA", "MX"}, + }, + }, + { + name: "multiple entries", + input: []string{ + "https://example.com US,CA,MX", + "https://another.com DE,FR", + }, + want: map[string][]string{ + "https://example.com": {"US", "CA", "MX"}, + "https://another.com": {"DE", "FR"}, + }, + }, + } + printer := print.NewPrinter() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseGeofencing(printer, tt.input) + if !reflect.DeepEqual(got, &tt.want) { + t.Errorf("ParseGeofencing() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseOriginRequestHeaders(t *testing.T) { + tests := []struct { + name string + input []string + want map[string]string + }{ + { + name: "empty input", + input: nil, + want: map[string]string{}, + }, + { + name: "single entry", + input: []string{ + "X-Custom-Header: Value1", + }, + want: map[string]string{ + "X-Custom-Header": "Value1", + }, + }, + { + name: "multiple entries", + input: []string{ + "X-Custom-Header1: Value1", + "X-Custom-Header2: Value2", + }, + want: map[string]string{ + "X-Custom-Header1": "Value1", + "X-Custom-Header2": "Value2", + }, + }, + } + printer := print.NewPrinter() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseOriginRequestHeaders(printer, tt.input) + if !reflect.DeepEqual(got, &tt.want) { + t.Errorf("ParseOriginRequestHeaders() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/pkg/services/dns/client/client.go b/internal/pkg/services/dns/client/client.go index 384bc2cca..478fa0a53 100644 --- a/internal/pkg/services/dns/client/client.go +++ b/internal/pkg/services/dns/client/client.go @@ -1,45 +1,15 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/dns" ) -func ConfigureClient(p *print.Printer) (*dns.APIClient, error) { - var err error - var apiClient *dns.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.DNSCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = dns.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*dns.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.DNSCustomEndpointKey), false, genericclient.CreateApiClient[*dns.APIClient](dns.NewAPIClient)) } diff --git a/internal/pkg/services/dns/utils/utils.go b/internal/pkg/services/dns/utils/utils.go index 351cf2e47..030c86b55 100644 --- a/internal/pkg/services/dns/utils/utils.go +++ b/internal/pkg/services/dns/utils/utils.go @@ -5,8 +5,9 @@ import ( "fmt" "math" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-sdk-go/services/dns" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) type DNSClient interface { @@ -35,7 +36,7 @@ func GetRecordSetType(ctx context.Context, apiClient DNSClient, projectId, zoneI if err != nil { return utils.Ptr(""), fmt.Errorf("get DNS recordset: %w", err) } - return resp.Rrset.Type, nil + return (*string)(resp.Rrset.Type), nil } func FormatTxtRecord(input string) (string, error) { diff --git a/internal/pkg/services/dns/utils/utils_test.go b/internal/pkg/services/dns/utils/utils_test.go index 12cae8fdc..b7c91bff9 100644 --- a/internal/pkg/services/dns/utils/utils_test.go +++ b/internal/pkg/services/dns/utils/utils_test.go @@ -6,8 +6,9 @@ import ( "testing" "github.com/google/uuid" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-sdk-go/services/dns" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) var ( diff --git a/internal/pkg/services/edge/client/client.go b/internal/pkg/services/edge/client/client.go new file mode 100644 index 000000000..77d2677ea --- /dev/null +++ b/internal/pkg/services/edge/client/client.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package client + +import ( + "context" + + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-sdk-go/services/edge" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +// APIClient is an interface that consolidates all client functionality to allow for mocking of the API client during testing. +type APIClient interface { + CreateInstance(ctx context.Context, projectId, regionId string) edge.ApiCreateInstanceRequest + DeleteInstance(ctx context.Context, projectId, regionId, instanceId string) edge.ApiDeleteInstanceRequest + DeleteInstanceByName(ctx context.Context, projectId, regionId, displayName string) edge.ApiDeleteInstanceByNameRequest + GetInstance(ctx context.Context, projectId, regionId, instanceId string) edge.ApiGetInstanceRequest + GetInstanceByName(ctx context.Context, projectId, regionId, displayName string) edge.ApiGetInstanceByNameRequest + ListInstances(ctx context.Context, projectId, regionId string) edge.ApiListInstancesRequest + UpdateInstance(ctx context.Context, projectId, regionId, instanceId string) edge.ApiUpdateInstanceRequest + UpdateInstanceByName(ctx context.Context, projectId, regionId, displayName string) edge.ApiUpdateInstanceByNameRequest + GetKubeconfigByInstanceId(ctx context.Context, projectId, regionId, instanceId string) edge.ApiGetKubeconfigByInstanceIdRequest + GetKubeconfigByInstanceName(ctx context.Context, projectId, regionId, displayName string) edge.ApiGetKubeconfigByInstanceNameRequest + GetTokenByInstanceId(ctx context.Context, projectId, regionId, instanceId string) edge.ApiGetTokenByInstanceIdRequest + GetTokenByInstanceName(ctx context.Context, projectId, regionId, displayName string) edge.ApiGetTokenByInstanceNameRequest + ListPlansProject(ctx context.Context, projectId string) edge.ApiListPlansProjectRequest +} + +// ConfigureClient configures and returns a new API client for the Edge service. +func ConfigureClient(p *print.Printer, cliVersion string) (APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.EdgeCustomEndpointKey), false, edge.NewAPIClient) +} diff --git a/internal/pkg/services/edge/common/error/error.go b/internal/pkg/services/edge/common/error/error.go new file mode 100755 index 000000000..2fd1433c3 --- /dev/null +++ b/internal/pkg/services/edge/common/error/error.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +// Package error provides custom error types for STACKIT Edge Cloud operations. +// +// This package defines structured error types that provide better error handling +// and type checking compared to simple string errors. Each error type can carry +// additional context and implements the standard error interface. +package error + +import ( + "fmt" +) + +// NoIdentifierError indicates that no identifier was provided when one was required. +type NoIdentifierError struct { + Operation string // Optional: which operation failed +} + +func (e *NoIdentifierError) Error() string { + if e.Operation != "" { + return fmt.Sprintf("no identifier provided for %s", e.Operation) + } + return "no identifier provided" +} + +// InvalidIdentifierError indicates that an unsupported identifier was provided. +type InvalidIdentifierError struct { + Identifier string // The invalid identifier that was provided +} + +func (e *InvalidIdentifierError) Error() string { + if e.Identifier != "" { + return fmt.Sprintf("unsupported identifier provided: %s", e.Identifier) + } + return "unsupported identifier provided" +} + +// InstanceExistsError indicates that a specific instance already exists. +type InstanceExistsError struct { + DisplayName string // Optional: the display name that was searched for +} + +func (e *InstanceExistsError) Error() string { + if e.DisplayName != "" { + return fmt.Sprintf("instance already exists: %s", e.DisplayName) + } + return "instance already exists" +} + +// NoInstanceError indicates that no instance was provided in a context where one was expected. +type NoInstanceError struct { + Context string // Optional: context where no instance was found (e.g., "in response", "in project") +} + +func (e *NoInstanceError) Error() string { + if e.Context != "" { + return fmt.Sprintf("no instance provided %s", e.Context) + } + return "no instance provided" +} + +// NewNoIdentifierError creates a new NoIdentifierError with optional context. +func NewNoIdentifierError(operation string) *NoIdentifierError { + return &NoIdentifierError{Operation: operation} +} + +// NewInvalidIdentifierError creates a new InvalidIdentifierError with the provided identifier. +func NewInvalidIdentifierError(identifier string) *InvalidIdentifierError { + return &InvalidIdentifierError{ + Identifier: identifier, + } +} + +// NewInstanceExistsError creates a new InstanceExistsError with optional instance details. +func NewInstanceExistsError(displayName string) *InstanceExistsError { + return &InstanceExistsError{ + DisplayName: displayName, + } +} + +// NewNoInstanceError creates a new NoInstanceError with optional context. +func NewNoInstanceError(context string) *NoInstanceError { + return &NoInstanceError{Context: context} +} diff --git a/internal/pkg/services/edge/common/error/error_test.go b/internal/pkg/services/edge/common/error/error_test.go new file mode 100755 index 000000000..1268d898a --- /dev/null +++ b/internal/pkg/services/edge/common/error/error_test.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +// Unit tests for package error +package error + +import ( + "testing" + + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +func TestNoIdentifierError(t *testing.T) { + type args struct { + operation string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + operation: "", + }, + want: "no identifier provided", + }, + { + name: "with operation", + args: args{ + operation: "create", + }, + want: "no identifier provided for create", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&NoIdentifierError{Operation: tt.args.operation}).Error() + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestInvalidIdentifierError(t *testing.T) { + type args struct { + id string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + id: "", + }, + want: "unsupported identifier provided", + }, + { + name: "with identifier", + args: args{ + id: "x-123", + }, + want: "unsupported identifier provided: x-123", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&InvalidIdentifierError{Identifier: tt.args.id}).Error() + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestInstanceExistsError(t *testing.T) { + type args struct { + name string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{name: ""}, + want: "instance already exists"}, + { + name: "with display name", + args: args{name: "my-inst"}, + want: "instance already exists: my-inst", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&InstanceExistsError{DisplayName: tt.args.name}).Error() + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestNoInstanceError(t *testing.T) { + type args struct { + ctx string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + ctx: "", + }, + want: "no instance provided", + }, + { + name: "with context", + args: args{ + ctx: "in project", + }, + want: "no instance provided in project", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (&NoInstanceError{Context: tt.args.ctx}).Error() + testUtils.AssertValue(t, got, tt.want) + }) + } +} + +func TestConstructorsReturnExpected(t *testing.T) { + tests := []struct { + name string + got any + want any + }{ + { + name: "NoIdentifier operation", + got: NewNoIdentifierError("op").Operation, + want: "op", + }, + { + name: "InvalidIdentifier identifier", + got: NewInvalidIdentifierError("id").Identifier, + want: "id", + }, + { + name: "InstanceExists displayName", + got: NewInstanceExistsError("name").DisplayName, + want: "name", + }, + { + name: "NoInstance context", + got: NewNoInstanceError("ctx").Context, + want: "ctx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wantErr, wantIsErr := tt.want.(error) + gotErr, gotIsErr := tt.got.(error) + if wantIsErr { + if !gotIsErr { + t.Fatalf("expected error but got %T", tt.got) + } + testUtils.AssertError(t, gotErr, wantErr) + return + } + + testUtils.AssertValue(t, tt.got, tt.want) + }) + } +} diff --git a/internal/pkg/services/edge/common/instance/instance.go b/internal/pkg/services/edge/common/instance/instance.go new file mode 100644 index 000000000..6dc35c672 --- /dev/null +++ b/internal/pkg/services/edge/common/instance/instance.go @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package instance + +import ( + "fmt" + "regexp" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + cliUtils "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +// Validation constants taken from OpenApi spec. +const ( + displayNameMinimumChars = 4 + displayNameMaximumChars = 8 + displayNameRegex = `^[a-z]([-a-z0-9]*[a-z0-9])?$` + descriptionMaxLength = 256 + instanceIdMaxLength = 16 + instanceIdMinLength = displayNameMinimumChars + 1 // Instance ID is generated by extending the display name. +) + +// User input flags for instance commands +const ( + DisplayNameFlag = "name" // > displayNameMinimumChars <= displayNameMaximumChars characters + regex displayNameRegex + DescriptionFlag = "description" // <= descriptionMaxLength characters + PlanIdFlag = "plan-id" // UUID + InstanceIdFlag = "id" // instance id (unique per project) +) + +// Flag usage texts +const ( + DisplayNameUsage = "The displayed name to distinguish multiple instances." + DescriptionUsage = "A user chosen description to distinguish multiple instances." + PlanIdUsage = "Service Plan configures the size of the Instance." + InstanceIdUsage = "The project-unique identifier of this instance." +) + +// Flag shorthands +const ( + DisplayNameShorthand = "n" + DescriptionShorthand = "d" + InstanceIdShorthand = "i" +) + +// OpenApi generated code will have different types for by-instance-id and by-display-name API calls, which are currently impl. as separate endpoints. +// To make the code more flexible, we use a struct to hold the request model. +type RequestModel struct { + Value any +} + +func ValidateDisplayName(displayName *string) error { + if displayName == nil { + return &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s may not be empty", DisplayNameFlag), + } + } + + if len(*displayName) > displayNameMaximumChars { + return &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DisplayNameFlag, displayNameMaximumChars), + } + } + if len(*displayName) < displayNameMinimumChars { + return &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", DisplayNameFlag, displayNameMinimumChars), + } + } + displayNameRegex := regexp.MustCompile(displayNameRegex) + if !displayNameRegex.MatchString(*displayName) { + return &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + } + } + return nil +} + +func ValidatePlanId(planId *string) error { + if planId == nil { + return &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s may not be empty", PlanIdFlag), + } + } + + err := cliUtils.ValidateUUID(*planId) + if err != nil { + return &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: %v", PlanIdFlag, err), + } + } + return nil +} + +func ValidateDescription(description string) error { + if len(description) > descriptionMaxLength { + return &cliErr.FlagValidationError{ + Flag: DescriptionFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DescriptionFlag, descriptionMaxLength), + } + } + + return nil +} + +func ValidateInstanceId(instanceId *string) error { + if instanceId == nil { + return &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s may not be empty", InstanceIdFlag), + } + } + + if *instanceId == "" { + return &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s may not be empty", InstanceIdFlag), + } + } + if len(*instanceId) < instanceIdMinLength { + return &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", InstanceIdFlag, instanceIdMinLength), + } + } + if len(*instanceId) > instanceIdMaxLength { + return &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", InstanceIdFlag, instanceIdMaxLength), + } + } + + return nil +} diff --git a/internal/pkg/services/edge/common/instance/instance_test.go b/internal/pkg/services/edge/common/instance/instance_test.go new file mode 100755 index 000000000..70a7dd11d --- /dev/null +++ b/internal/pkg/services/edge/common/instance/instance_test.go @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package instance + +import ( + "fmt" + "strings" + "testing" + + cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +func TestValidateDisplayName(t *testing.T) { + type args struct { + displayName *string + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "valid minimum length", + args: &args{displayName: utils.Ptr("test")}, + }, + { + name: "valid maximum length", + args: &args{displayName: utils.Ptr("testname")}, + }, + { + name: "valid with hyphens", + args: &args{displayName: utils.Ptr("test-app")}, + }, + { + name: "valid with numbers", + args: &args{displayName: utils.Ptr("test123")}, + }, + { + name: "valid starting with letter", + args: &args{displayName: utils.Ptr("a-test")}, + }, + + // Error cases - nil pointer + { + name: "nil display name", + args: &args{displayName: nil}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s may not be empty", DisplayNameFlag), + }, + }, + + // Error cases - length validation + { + name: "too short", + args: &args{displayName: utils.Ptr("abc")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", DisplayNameFlag, displayNameMinimumChars), + }, + }, + { + name: "too long", + args: &args{displayName: utils.Ptr("verylongname")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DisplayNameFlag, displayNameMaximumChars), + }, + }, + + // Error cases - regex validation + { + name: "starts with number", + args: &args{displayName: utils.Ptr("1test")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + { + name: "starts with hyphen", + args: &args{displayName: utils.Ptr("-test")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + { + name: "ends with hyphen", + args: &args{displayName: utils.Ptr("test-")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + { + name: "contains uppercase", + args: &args{displayName: utils.Ptr("Test")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + { + name: "contains special characters", + args: &args{displayName: utils.Ptr("test@")}, + want: &cliErr.FlagValidationError{ + Flag: DisplayNameFlag, + Details: fmt.Sprintf("%s didn't match the required regex expression %s", DisplayNameFlag, displayNameRegex), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateDisplayName(tt.args.displayName) + testUtils.AssertError(t, err, tt.want) + }) + } +} + +func TestValidatePlanId(t *testing.T) { + type args struct { + planId *string + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "valid UUID v4", + args: &args{planId: utils.Ptr("550e8400-e29b-41d4-a716-446655440000")}, + }, + { + name: "valid UUID lowercase", + args: &args{planId: utils.Ptr("6ba7b810-9dad-11d1-80b4-00c04fd430c8")}, + }, + { + name: "valid UUID uppercase", + args: &args{planId: utils.Ptr("6BA7B810-9DAD-11D1-80B4-00C04FD430C8")}, + }, + { + name: "valid UUID without hyphens", + args: &args{planId: utils.Ptr("550e8400e29b41d4a716446655440000")}, + }, + + // Error cases - nil pointer + { + name: "nil plan id", + args: &args{planId: nil}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s may not be empty", PlanIdFlag), + }, + }, + + // Error cases - invalid UUID format + { + name: "invalid UUID - too short", + args: &args{planId: utils.Ptr("550e8400-e29b-41d4-a716")}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: parse 550e8400-e29b-41d4-a716 as UUID: invalid UUID length: 23", PlanIdFlag), + }, + }, + { + name: "invalid UUID - invalid characters", + args: &args{planId: utils.Ptr("550e8400-e29b-41d4-a716-44665544000g")}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: parse 550e8400-e29b-41d4-a716-44665544000g as UUID: invalid UUID format", PlanIdFlag), + }, + }, + { + name: "not a UUID", + args: &args{planId: utils.Ptr("not-a-uuid")}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: parse not-a-uuid as UUID: invalid UUID length: 10", PlanIdFlag), + }, + }, + { + name: "empty string", + args: &args{planId: utils.Ptr("")}, + want: &cliErr.FlagValidationError{ + Flag: PlanIdFlag, + Details: fmt.Sprintf("%s is not a valid UUID: parse as UUID: invalid UUID length: 0", PlanIdFlag), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePlanId(tt.args.planId) + testUtils.AssertError(t, err, tt.want) + }) + } +} + +func TestValidateDescription(t *testing.T) { + type args struct { + description string + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "empty description", + args: &args{description: ""}, + }, + { + name: "short description", + args: &args{description: "A short description"}, + }, + { + name: "description at maximum length", + args: &args{description: strings.Repeat("a", descriptionMaxLength)}, + }, + { + name: "description with special characters", + args: &args{description: "Description with special chars: !@#$%^&*()"}, + }, + { + name: "description with unicode", + args: &args{description: "Description with unicode: 你好世界 🌍"}, + }, + + // Error cases + { + name: "description too long", + args: &args{description: strings.Repeat("a", descriptionMaxLength+1)}, + want: &cliErr.FlagValidationError{ + Flag: DescriptionFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DescriptionFlag, descriptionMaxLength), + }, + }, + { + name: "description way too long", + args: &args{description: strings.Repeat("a", descriptionMaxLength+100)}, + want: &cliErr.FlagValidationError{ + Flag: DescriptionFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", DescriptionFlag, descriptionMaxLength), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateDescription(tt.args.description) + testUtils.AssertError(t, err, tt.want) + }) + } +} + +func TestValidateInstanceId(t *testing.T) { + type args struct { + instanceId *string + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "valid instance id at minimum length", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMinLength))}, + }, + { + name: "valid instance id at maximum length", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMaxLength))}, + }, + { + name: "valid instance id with mixed characters", + args: &args{instanceId: utils.Ptr("test-instance")}, + }, + + // Error cases - nil pointer + { + name: "nil instance id", + args: &args{instanceId: nil}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s may not be empty", InstanceIdFlag), + }, + }, + + // Error cases - empty string + { + name: "empty string", + args: &args{instanceId: utils.Ptr("")}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s may not be empty", InstanceIdFlag), + }, + }, + + // Error cases - length validation + { + name: "too short", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMinLength-1))}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", InstanceIdFlag, instanceIdMinLength), + }, + }, + { + name: "way too short", + args: &args{instanceId: utils.Ptr("a")}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too short (minimum length is %d characters)", InstanceIdFlag, instanceIdMinLength), + }, + }, + { + name: "too long", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMaxLength+1))}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", InstanceIdFlag, instanceIdMaxLength), + }, + }, + { + name: "way too long", + args: &args{instanceId: utils.Ptr(strings.Repeat("a", instanceIdMaxLength+10))}, + want: &cliErr.FlagValidationError{ + Flag: InstanceIdFlag, + Details: fmt.Sprintf("%s is too long (maximum length is %d characters)", InstanceIdFlag, instanceIdMaxLength), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateInstanceId(tt.args.instanceId) + testUtils.AssertError(t, err, tt.want) + }) + } +} diff --git a/internal/pkg/services/edge/common/kubeconfig/kubeconfig.go b/internal/pkg/services/edge/common/kubeconfig/kubeconfig.go new file mode 100755 index 000000000..ef0918c8b --- /dev/null +++ b/internal/pkg/services/edge/common/kubeconfig/kubeconfig.go @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package kubeconfig + +import ( + "fmt" + "maps" + "math" + "os" + "path/filepath" + + "k8s.io/client-go/tools/clientcmd" +) + +// Validation constants taken from OpenApi spec. +const ( + expirationSecondsMax = 15552000 // 60 * 60 * 24 * 180 seconds = 180 days + expirationSecondsMin = 600 // 60 * 10 seconds = 10 minutes +) + +// Defaults taken from OpenApi spec. +const ( + ExpirationSecondsDefault = 3600 // 60 * 60 seconds = 1 hour +) + +// User input flags for kubeconfig commands +const ( + ExpirationFlag = "expiration" + DisableWritingFlag = "disable-writing" + FilepathFlag = "filepath" + OverwriteFlag = "overwrite" + SwitchContextFlag = "switch-context" +) + +// Flag usage texts +const ( + ExpirationUsage = "Expiration time for the kubeconfig, e.g. 5d. By default, the token is valid for 1h." + FilepathUsage = "Path to the kubeconfig file. A default is chosen by Kubernetes if not set." + DisableWritingUsage = "Disable writing the kubeconfig to a file." + OverwriteUsage = "Force overwrite the kubeconfig file if it exists." + SwitchContextUsage = "Switch to the context in the kubeconfig file to the new context." +) + +// Flag shorthands +const ( + ExpirationShorthand = "e" + DisableWritingShorthand = "" + FilepathShorthand = "f" + OverwriteShorthand = "" + SwitchContextShorthand = "" +) + +func ValidateExpiration(expiration *uint64) error { + if expiration != nil { + // We're using utils.ConvertToSeconds to convert the user input string to seconds, which is using + // math.MaxUint64 internally, if no special limits are set. However: the OpenApi v3 Spec + // only allows integers (int64). So we could end up in a overflow IF expirationSecondsMax + // ever is changed beyond the maximum value of int64. This check makes sure this won't happen. + maxExpiration := uint64(math.Min(expirationSecondsMax, math.MaxInt64)) + if *expiration > maxExpiration { + return fmt.Errorf("%s is too large (maximum is %d seconds)", ExpirationFlag, maxExpiration) + } + // If expiration is ever changed to int64 this check makes sure we never end up with negative expiration times. + minExpiration := uint64(math.Max(expirationSecondsMin, 0)) + if *expiration < minExpiration { + return fmt.Errorf("%s is too small (minimum is %d seconds)", ExpirationFlag, minExpiration) + } + } + return nil +} + +// EmptyKubeconfigError is returned when the kubeconfig content is empty. +type EmptyKubeconfigError struct{} + +// Error returns the error message. +func (e *EmptyKubeconfigError) Error() string { + return "no data for kubeconfig" +} + +// LoadKubeconfigError is returned when loading the kubeconfig fails. +type LoadKubeconfigError struct { + Err error +} + +// Error returns the error message. +func (e *LoadKubeconfigError) Error() string { + return fmt.Sprintf("load kubeconfig: %v", e.Err) +} + +// Unwrap returns the underlying error. +func (e *LoadKubeconfigError) Unwrap() error { + return e.Err +} + +// WriteKubeconfigError is returned when writing the kubeconfig fails. +type WriteKubeconfigError struct { + Err error +} + +// Error returns the error message. +func (e *WriteKubeconfigError) Error() string { + return fmt.Sprintf("write kubeconfig: %v", e.Err) +} + +// Unwrap returns the underlying error. +func (e *WriteKubeconfigError) Unwrap() error { + return e.Err +} + +// InvalidKubeconfigPathError is returned when an invalid kubeconfig path is provided. +type InvalidKubeconfigPathError struct { + Path string +} + +// Error returns the error message. +func (e *InvalidKubeconfigPathError) Error() string { + return fmt.Sprintf("invalid path: %s", e.Path) +} + +// mergeKubeconfig merges new kubeconfig data into a kubeconfig file. +// +// If the destination file does not exist, it will be created. If the file exists, +// the new data (clusters, contexts, and users) is merged into the existing +// configuration, overwriting entries with the same name and replacing the +// current-context if defined in the new data. +// +// The function takes the following parameters: +// - configPath: The path to the destination file. The file and the directory tree +// for the file will be created if it does not exist. +// - data: The new kubeconfig content to merge. Merge is performed based on standard +// kubeconfig structure. +// - switchContext: If true, the function will switch to the new context in the +// kubeconfig file after merging. +// +// It returns a nil error on success. On failure, it returns an error indicating +// if the provided data was empty, malformed, or if there were issues reading from +// or writing to the filesystem. +func mergeKubeconfig(filePath *string, data string, switchContext bool) error { + if filePath == nil { + return fmt.Errorf("no kubeconfig file provided to be merged") + } + path := *filePath + + // Check if the new kubeconfig data is empty + if data == "" { + return &EmptyKubeconfigError{} + } + + // Load and validate the data into a kubeconfig object + newConfig, err := clientcmd.Load([]byte(data)) + if err != nil { + return &LoadKubeconfigError{Err: err} + } + + // If the destination kubeconfig does not exist, create a new one. IsNotExist will ignore other errors. + // Other errors are handled separately by the following clientcmd.LoadFromFile clientcmd.LoadFromFile + if _, err := os.Stat(path); os.IsNotExist(err) { + return writeKubeconfig(&path, data) + } + + // If the file exists load and validate the existing kubeconfig into a config object + existingConfig, err := clientcmd.LoadFromFile(path) + if err != nil { + return &LoadKubeconfigError{Err: err} + } + + // Merge the new kubeconfig data into the existing config object + maps.Copy(existingConfig.AuthInfos, newConfig.AuthInfos) + maps.Copy(existingConfig.Clusters, newConfig.Clusters) + maps.Copy(existingConfig.Contexts, newConfig.Contexts) + + // If no CurrentContext is set or switchContext is true, set the CurrentContext to the CurrentContext of the new kubeconfig + if newConfig.CurrentContext != "" && (switchContext || existingConfig.CurrentContext == "") { + existingConfig.CurrentContext = newConfig.CurrentContext + } + + // Save the merged config to the file, creating missing directories as needed. + if err := clientcmd.WriteToFile(*existingConfig, path); err != nil { + return &WriteKubeconfigError{Err: err} + } + + return nil +} + +// writeKubeconfig writes kubeconfig data to a file, overwriting it if it exists. +// +// The function takes the following parameters: +// - configPath: The path to the destination file. The file and the directory tree +// for the file will be created if it does not exist. +// - data: The new kubeconfig content to write to the file. +// +// It returns a nil error on success. On failure, it returns an error indicating +// if the provided data was empty, malformed, or if there were issues reading from +// or writing to the filesystem. +func writeKubeconfig(filePath *string, data string) error { + if filePath == nil { + return fmt.Errorf("no kubeconfig file provided to be written") + } + path := *filePath + + // Check if the new kubeconfig data is empty + if data == "" { + return &EmptyKubeconfigError{} + } + + // Load and validate the data into a kubeconfig object + config, err := clientcmd.Load([]byte(data)) + if err != nil { + return &LoadKubeconfigError{Err: err} + } + + // Save the merged config to the file, creating missing directories as needed. + if err := clientcmd.WriteToFile(*config, path); err != nil { + return &WriteKubeconfigError{Err: err} + } + + return nil +} + +// getDefaultKubeconfigPath returns the default location for the kubeconfig file, +// following standard Kubernetes loading rules. +// +// It returns a string containing the absolute path to the default kubeconfig file. +func getDefaultKubeconfigPath() string { + return clientcmd.NewDefaultClientConfigLoadingRules().GetDefaultFilename() +} + +// Returns the absolute path to the kubeconfig file. +// If a file path is provided, it is validated and, if valid, returned as an absolute path. +// If nil is provided the default kubeconfig path is loaded and returned as an absolute path. +func getKubeconfigPath(filePath *string) (string, error) { + if filePath == nil { + return getDefaultKubeconfigPath(), nil + } + + if isValidFilePath(filePath) { + return filepath.Abs(*filePath) + } + return "", &InvalidKubeconfigPathError{Path: *filePath} +} + +// Basic filesystem path validation. Returns true if the provided string is a path. Returns false otherwise. +func isValidFilePath(filePath *string) bool { + if filePath == nil || *filePath == "" { + return false + } + + // Clean the path and check if it's valid + cleaned := filepath.Clean(*filePath) + if cleaned == "." || cleaned == string(filepath.Separator) { + return false + } + + // Try to get absolute path (this will fail for invalid paths) + _, err := filepath.Abs(*filePath) + // If no error, the path is valid (return true). Otherwise, it's invalid (return false). + return err == nil +} + +// Basic filesystem file existence check. Returns true if the file exists. Returns false otherwise. +func isExistingFile(filePath *string) bool { + // Check if the kubeconfig file exists + _, errStat := os.Stat(*filePath) + return !os.IsNotExist(errStat) +} + +// ConfirmationCallback is a function that prompts for confirmation with the given message +// and returns true if confirmed, false otherwise +type ConfirmationCallback func(message string) error + +// WriteOptions contains options for writing kubeconfig files +type WriteOptions struct { + Overwrite bool + SwitchContext bool + ConfirmFn ConfirmationCallback +} + +// WithOverwrite sets whether to overwrite existing files instead of merging +func (w WriteOptions) WithOverwrite(overwrite bool) WriteOptions { + w.Overwrite = overwrite + return w +} + +// WithSwitchContext sets whether to switch to the new context after writing +func (w WriteOptions) WithSwitchContext(switchContext bool) WriteOptions { + w.SwitchContext = switchContext + return w +} + +// WithConfirmation sets the confirmation callback function +func (w WriteOptions) WithConfirmation(fn ConfirmationCallback) WriteOptions { + w.ConfirmFn = fn + return w +} + +// NewWriteOptions creates a new WriteOptions with default values +func NewWriteOptions() WriteOptions { + return WriteOptions{ + Overwrite: false, + SwitchContext: false, + ConfirmFn: nil, + } +} + +// WriteKubeconfig writes the provided kubeconfig data to a file on the filesystem. +// By default, if the file already exists, it will be merged with the provided data. +// This behavior can be controlled using the provided options. +// +// The function takes the following parameters: +// - filePath: The path to the destination file. The file and the directory tree for the +// file will be created if it does not exist. If nil, the default kubeconfig path is used. +// - kubeconfig: The kubeconfig content to write. +// - options: Options for controlling the write behavior. +// +// It returns the file path actually used to write to on success. +func WriteKubeconfig(filePath *string, kubeconfig string, options WriteOptions) (*string, error) { + // Check if the provided filePath is valid or use the default kubeconfig path no filePath is provided + path, err := getKubeconfigPath(filePath) + if err != nil { + return nil, err + } + + if isExistingFile(&path) { + // If the file exists + if !options.Overwrite { + // If overwrite was not requested the default it to merge + if options.ConfirmFn != nil { + // If confirmation callback is provided, prompt the user for confirmation + prompt := fmt.Sprintf("Update your kubeconfig %q?", path) + err := options.ConfirmFn(prompt) + if err != nil { + // If the user doesn't confirm do not proceed with the merge + return nil, err + } + } + err := mergeKubeconfig(&path, kubeconfig, options.SwitchContext) + if err != nil { + return nil, err + } + return &path, err + } + // If overwrite was requested overwrite the existing file + if options.ConfirmFn != nil { + // If confirmation callback is provided, prompt the user for confirmation + prompt := fmt.Sprintf("Replace your kubeconfig %q?", path) + err := options.ConfirmFn(prompt) + if err != nil { + // If the user doesn't confirm do not proceed with the overwrite + return nil, err + } + // Fallthrough + } + } + // If the file doesn't exist or in case the user confirmed the overwrite (fallthrough) write the file + err = writeKubeconfig(&path, kubeconfig) + if err != nil { + return nil, err + } + return &path, err +} diff --git a/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go b/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go new file mode 100755 index 000000000..e196052c4 --- /dev/null +++ b/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go @@ -0,0 +1,745 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package kubeconfig + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "k8s.io/client-go/tools/clientcmd" + + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +var ( + testErrorMessage = "test error message" + errStringErrTest = errors.New(testErrorMessage) +) + +const ( + kubeconfig_1_yaml = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-1.com + name: cluster-1 +contexts: +- context: + cluster: cluster-1 + user: user-1 + name: context-1 +current-context: context-1 +kind: Config +preferences: {} +users: +- name: user-1 + user: {} +` + kubeconfig_2_yaml = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-2.com + name: cluster-2 +contexts: +- context: + cluster: cluster-2 + user: user-2 + name: context-2 +current-context: context-2 +kind: Config +users: +- name: user-2 + user: {} +` + overwriteKubeconfigTarget = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-1.com + name: cluster-1 +contexts: +- context: + cluster: cluster-1 + user: user-1 + name: context-1 +current-context: context-1 +kind: Config +users: +- name: user-1 + user: + token: old-token +` + overwriteKubeconfigSource = ` +apiVersion: v1 +clusters: +- cluster: + server: https://server-1-new.com + name: cluster-1 +contexts: +- context: + cluster: cluster-1 + user: user-1 + name: context-1 +current-context: context-1 +kind: Config +users: +- name: user-1 + user: + token: new-token +` +) + +func TestValidateExpiration(t *testing.T) { + type args struct { + expiration *uint64 + } + tests := []struct { + name string + args *args + want error + }{ + // Valid cases + { + name: "nil expiration", + args: &args{ + expiration: nil, + }, + }, + { + name: "valid expiration - minimum value", + args: &args{ + expiration: utils.Ptr(uint64(expirationSecondsMin)), + }, + }, + { + name: "valid expiration - maximum value", + args: &args{ + expiration: utils.Ptr(uint64(expirationSecondsMax)), + }, + }, + { + name: "valid expiration - default value", + args: &args{ + expiration: utils.Ptr(uint64(ExpirationSecondsDefault)), + }, + }, + { + name: "valid expiration - middle value", + args: &args{ + expiration: utils.Ptr(uint64(86400)), // 1 day + }, + }, + + // Error cases - below minimum + { + name: "expiration too small - below minimum", + args: &args{ + expiration: utils.Ptr(uint64(expirationSecondsMin - 1)), + }, + want: fmt.Errorf("%s is too small (minimum is %d seconds)", ExpirationFlag, expirationSecondsMin), + }, + { + name: "expiration too small - zero", + args: &args{ + expiration: utils.Ptr(uint64(0)), + }, + want: fmt.Errorf("%s is too small (minimum is %d seconds)", ExpirationFlag, expirationSecondsMin), + }, + + // Error cases - above maximum + { + name: "expiration too large - above maximum", + args: &args{ + expiration: utils.Ptr(uint64(expirationSecondsMax + 1)), + }, + want: fmt.Errorf("%s is too large (maximum is %d seconds)", ExpirationFlag, expirationSecondsMax), + }, + { + name: "expiration too large - way above maximum", + args: &args{ + expiration: utils.Ptr(uint64(9999999999999999999)), + }, + want: fmt.Errorf("%s is too large (maximum is %d seconds)", ExpirationFlag, expirationSecondsMax), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateExpiration(tt.args.expiration) + testUtils.AssertError(t, err, tt.want) + }) + } +} + +func TestErrors(t *testing.T) { + type args struct { + err error + } + tests := []struct { + name string + args *args + wantErr error + }{ + // EmptyKubeconfigError + { + name: "EmptyKubeconfigError", + args: &args{ + err: &EmptyKubeconfigError{}, + }, + wantErr: &EmptyKubeconfigError{}, + }, + + // LoadKubeconfigError + { + name: "LoadKubeconfigError", + args: &args{ + err: &LoadKubeconfigError{Err: errStringErrTest}, + }, + wantErr: errStringErrTest, + }, + + // WriteKubeconfigError + { + name: "WriteKubeconfigError", + args: &args{ + err: &WriteKubeconfigError{Err: errStringErrTest}, + }, + wantErr: errStringErrTest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testUtils.AssertError(t, tt.args.err, tt.wantErr) + }) + } +} + +// Already have comprehensive tests for WriteKubeconfig + +func TestWriteOptions(t *testing.T) { + confirmFn := func(_ string) error { return nil } + + type args struct { + modify func(WriteOptions) WriteOptions + check func(*testing.T, WriteOptions) + } + tests := []struct { + name string + args *args + }{ + // Default options + { + name: "NewWriteOptions creates default options", + args: &args{ + modify: func(o WriteOptions) WriteOptions { return o }, + check: func(t *testing.T, opts WriteOptions) { + if opts.Overwrite { + t.Error("expected Overwrite to be false by default") + } + if opts.SwitchContext { + t.Error("expected SwitchContext to be false by default") + } + if opts.ConfirmFn != nil { + t.Error("expected ConfirmFn to be nil by default") + } + }, + }, + }, + + // Individual option tests + { + name: "WithOverwrite sets overwrite flag", + args: &args{ + modify: func(o WriteOptions) WriteOptions { return o.WithOverwrite(true) }, + check: func(t *testing.T, opts WriteOptions) { + if !opts.Overwrite { + t.Error("expected Overwrite to be true") + } + }, + }, + }, + { + name: "WithSwitchContext sets switch context flag", + args: &args{ + modify: func(o WriteOptions) WriteOptions { return o.WithSwitchContext(true) }, + check: func(t *testing.T, opts WriteOptions) { + if !opts.SwitchContext { + t.Error("expected SwitchContext to be true") + } + }, + }, + }, + { + name: "WithConfirmation sets confirmation callback", + args: &args{ + modify: func(o WriteOptions) WriteOptions { return o.WithConfirmation(confirmFn) }, + check: func(t *testing.T, opts WriteOptions) { + if opts.ConfirmFn == nil { + t.Error("expected ConfirmFn to be set") + } + }, + }, + }, + + // Chained options + { + name: "options are chainable", + args: &args{ + modify: func(o WriteOptions) WriteOptions { + return o.WithOverwrite(true). + WithSwitchContext(true). + WithConfirmation(confirmFn) + }, + check: func(t *testing.T, opts WriteOptions) { + if !opts.Overwrite { + t.Error("expected Overwrite to be true") + } + if !opts.SwitchContext { + t.Error("expected SwitchContext to be true") + } + if opts.ConfirmFn == nil { + t.Error("expected ConfirmFn to be set") + } + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := tt.args.modify(NewWriteOptions()) + tt.args.check(t, opts) + }) + } +} + +func TestGetDefaultKubeconfigPath(t *testing.T) { + type args struct { + kubeconfigEnv *string // nil means unset + } + tests := []struct { + name string + args *args + want string + }{ + // KUBECONFIG not set + { + name: "returns a non-empty path when KUBECONFIG is not set", + args: &args{kubeconfigEnv: nil}, + want: "", + }, + + // Single path + { + name: "returns path from KUBECONFIG if set", + args: &args{kubeconfigEnv: utils.Ptr("/test/kubeconfig_1_yaml")}, + want: "/test/kubeconfig_1_yaml", + }, + + // Multiple paths + { + name: "returns first path from KUBECONFIG if multiple are set", + args: &args{kubeconfigEnv: utils.Ptr("/test/kubeconfig_1_yaml" + string(os.PathListSeparator) + "/test/kubeconfig_2_yaml")}, + want: "/test/kubeconfig_1_yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original env and restore after test + oldKubeconfig := os.Getenv("KUBECONFIG") + defer func() { + if err := os.Setenv("KUBECONFIG", oldKubeconfig); err != nil { + t.Logf("failed to restore KUBECONFIG: %v", err) + } + }() + + // Setup test environment + if tt.args.kubeconfigEnv == nil { + if err := os.Unsetenv("KUBECONFIG"); err != nil { + t.Fatalf("failed to unset KUBECONFIG: %v", err) + } + } else { + if err := os.Setenv("KUBECONFIG", *tt.args.kubeconfigEnv); err != nil { + t.Fatalf("failed to set KUBECONFIG: %v", err) + } + } + + // Run test + got := getDefaultKubeconfigPath() + + // If want is empty only make sure the returned path is not empty + // In that case we don't care about what path is default, only that one is. + want := filepath.Clean(tt.want) + if want == filepath.Clean("") { + if filepath.Clean(got) != "" { + return + } + } + + // Verify results + testUtils.AssertValue(t, filepath.Clean(got), want) + }) + } +} + +func TestGetKubeconfigPath(t *testing.T) { + type args struct { + path *string + checkPath func(t *testing.T, path string) + } + tests := []struct { + name string + args *args + wantErr error + }{ + { + name: "uses default path when nil provided", + args: &args{ + path: nil, + checkPath: func(t *testing.T, path string) { + if path == "" { + t.Error("expected non-empty path") + } + }, + }, + }, + { + name: "validates and returns absolute path when valid path provided", + args: &args{ + path: utils.Ptr("/tmp/kubeconfig"), + checkPath: func(t *testing.T, path string) { + if !filepath.IsAbs(path) { + t.Error("expected absolute path") + } + }, + }, + }, + { + name: "returns error for invalid path", + args: &args{ + path: utils.Ptr("."), + }, + wantErr: &InvalidKubeconfigPathError{Path: "."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := getKubeconfigPath(tt.args.path) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if tt.args.checkPath != nil { + tt.args.checkPath(t, path) + } + }) + } +} + +func TestIsValidFilePath(t *testing.T) { + type args struct { + path *string + } + tests := []struct { + name string + args *args + + want bool + }{ + { + name: "valid path", + args: &args{ + path: utils.Ptr("/test/kubeconfig"), + }, + want: true, + }, + { + name: "nil path", + args: &args{ + path: nil, + }, + want: false, + }, + { + name: "empty path", + args: &args{ + path: utils.Ptr(""), + }, + want: false, + }, + { + name: "single dot", + args: &args{ + path: utils.Ptr("."), + }, + want: false, + }, + { + name: "single slash", + args: &args{ + path: utils.Ptr("/"), + }, + want: false, + }, + { + name: "relative path with parent directory", + args: &args{ + path: utils.Ptr("../kubeconfig"), + }, + want: true, + }, + { + name: "path with spaces", + args: &args{ + path: utils.Ptr("/test/kube config"), + }, + want: true, + }, + { + name: "complex but valid path", + args: &args{ + path: utils.Ptr("/test/kube-config.d/cluster1/config"), + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isValidFilePath(tt.args.path); got != tt.want { + t.Errorf("isValidFilePath() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWriteKubeconfig(t *testing.T) { + testPath := filepath.Join(t.TempDir(), "config") + defaultTempFile := filepath.Join(t.TempDir(), "default-kubeconfig") + + type args struct { + path *string + content string + options WriteOptions + setupEnv func() + checkFile func(t *testing.T, path string) + } + tests := []struct { + name string + args *args + wantPath *string + wantErr any + }{ + { + name: "writes new file with default options", + args: &args{ + path: &testPath, + content: kubeconfig_1_yaml, + options: NewWriteOptions(), + checkFile: func(t *testing.T, path string) { + if !isExistingFile(&path) { + t.Error("file was not created") + } + }, + }, + wantPath: &testPath, + }, + { + name: "handles invalid file path", + args: &args{ + path: utils.Ptr("."), + content: kubeconfig_1_yaml, + options: NewWriteOptions(), + }, + wantErr: &InvalidKubeconfigPathError{Path: "."}, + }, + { + name: "handles empty kubeconfig", + args: &args{ + path: &testPath, + content: "", + options: NewWriteOptions(), + }, + wantErr: &EmptyKubeconfigError{}, + }, + { + name: "uses default path when nil provided", + args: &args{ + path: nil, + content: kubeconfig_1_yaml, + options: NewWriteOptions(), + setupEnv: func() { + t.Setenv("KUBECONFIG", defaultTempFile) + }, + }, + wantPath: &defaultTempFile, + }, + { + name: "overwrites existing file when option is set", + args: &args{ + path: &testPath, + content: kubeconfig_2_yaml, + options: NewWriteOptions().WithOverwrite(true), + setupEnv: func() { + // Pre-write first file + if _, err := WriteKubeconfig(&testPath, kubeconfig_1_yaml, NewWriteOptions()); err != nil { + t.Fatalf("failed to setup test: %v", err) + } + }, + checkFile: func(t *testing.T, path string) { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read kubeconfig: %v", err) + } + if !strings.Contains(string(content), "server-2.com") { + t.Error("file was not overwritten") + } + }, + }, + wantPath: &testPath, + }, + { + name: "respects user confirmation - confirmed", + args: &args{ + path: &testPath, + content: kubeconfig_1_yaml, + options: NewWriteOptions().WithConfirmation(func(_ string) error { + return nil + }), + }, + wantPath: &testPath, + }, + { + name: "respects user confirmation - denied", + args: &args{ + path: &testPath, + content: kubeconfig_1_yaml, + options: NewWriteOptions().WithConfirmation(func(_ string) error { + return errStringErrTest + }), + }, + wantErr: errStringErrTest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.setupEnv != nil { + tt.args.setupEnv() + } + + got, gotErr := WriteKubeconfig(tt.args.path, tt.args.content, tt.args.options) + if !testUtils.AssertError(t, gotErr, tt.wantErr) { + return + } + + testUtils.AssertValue(t, got, tt.wantPath) + + if tt.args.checkFile != nil { + tt.args.checkFile(t, *got) + } + }) + } +} + +func TestMergeKubeconfig(t *testing.T) { + type args struct { + path *string + content string + switchCtx bool + setupEnv func() + } + tests := []struct { + name string + args args + verify func(t *testing.T, path string) + wantErr error + }{ + { + name: "merges configs with conflicting names", + args: args{ + path: utils.Ptr(filepath.Join(t.TempDir(), "kubeconfig")), + content: overwriteKubeconfigSource, + switchCtx: true, + setupEnv: func() { + // Pre-write first file + if _, err := WriteKubeconfig(utils.Ptr(filepath.Join(t.TempDir(), "kubeconfig")), overwriteKubeconfigTarget, NewWriteOptions()); err != nil { + t.Fatalf("failed to setup test: %v", err) + } + }, + }, + verify: func(t *testing.T, path string) { + config, err := clientcmd.LoadFromFile(path) + if err != nil { + t.Fatalf("failed to load merged config: %v", err) + } + + cluster := config.Clusters["cluster-1"] + if cluster.Server != "https://server-1-new.com" { + t.Errorf("expected server to be 'https://server-1-new.com', got '%s'", cluster.Server) + } + + user := config.AuthInfos["user-1"] + if user.Token != "new-token" { + t.Errorf("expected token to be 'new-token', got '%s'", user.Token) + } + }, + }, + { + name: "handles nil file path", + args: args{ + path: nil, + content: kubeconfig_1_yaml, + switchCtx: false, + }, + wantErr: fmt.Errorf("no kubeconfig file provided to be merged"), + }, + { + name: "handles invalid config", + args: args{ + path: utils.Ptr(filepath.Join(t.TempDir(), "kubeconfig")), + content: "invalid yaml", + switchCtx: false, + }, + wantErr: &LoadKubeconfigError{}, + }, + { + name: "handles empty config", + args: args{ + path: utils.Ptr(filepath.Join(t.TempDir(), "kubeconfig")), + content: "", + switchCtx: false, + }, + wantErr: &EmptyKubeconfigError{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.setupEnv != nil { + tt.args.setupEnv() + } + + err := mergeKubeconfig(tt.args.path, tt.args.content, tt.args.switchCtx) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + + if tt.verify != nil { + if tt.args.path == nil { + t.Fatalf("expected path to be set") + } + tt.verify(t, *tt.args.path) + } + }) + } +} diff --git a/internal/pkg/services/edge/common/validation/input.go b/internal/pkg/services/edge/common/validation/input.go new file mode 100644 index 000000000..c32f8a9be --- /dev/null +++ b/internal/pkg/services/edge/common/validation/input.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package validation + +import ( + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" +) + +// Struct to model the instance identifier provided by the user (either instance-id or display-name) +type Identifier struct { + Flag string + Value string +} + +// GetValidatedInstanceIdentifier gets and validates the instance identifier provided by the user through the command-line flags. +// It checks for either an instance ID or a display name and validates the provided value. +// +// p is the printer used for logging. +// cmd is the cobra command that holds the flags. +// +// Returns an Identifier struct containing the flag and its value if a valid identifier is provided, otherwise returns an error. +// Indirect unit tests of GetValidatedInstanceIdentifier are done within the respective CLI packages. +func GetValidatedInstanceIdentifier(p *print.Printer, cmd *cobra.Command) (*Identifier, error) { + switch { + case cmd.Flags().Changed(commonInstance.InstanceIdFlag): + instanceIdValue := flags.FlagToStringPointer(p, cmd, commonInstance.InstanceIdFlag) + if err := commonInstance.ValidateInstanceId(instanceIdValue); err != nil { + return nil, err + } + return &Identifier{ + Flag: commonInstance.InstanceIdFlag, + Value: *instanceIdValue, + }, nil + case cmd.Flags().Changed(commonInstance.DisplayNameFlag): + displayNameValue := flags.FlagToStringPointer(p, cmd, commonInstance.DisplayNameFlag) + if err := commonInstance.ValidateDisplayName(displayNameValue); err != nil { + return nil, err + } + return &Identifier{ + Flag: commonInstance.DisplayNameFlag, + Value: *displayNameValue, + }, nil + default: + return nil, commonErr.NewNoIdentifierError("") + } +} diff --git a/internal/pkg/services/edge/common/validation/input_test.go b/internal/pkg/services/edge/common/validation/input_test.go new file mode 100755 index 000000000..b058f1427 --- /dev/null +++ b/internal/pkg/services/edge/common/validation/input_test.go @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package validation + +import ( + "testing" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + commonErr "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/error" + commonInstance "github.com/stackitcloud/stackit-cli/internal/pkg/services/edge/common/instance" + testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils" +) + +func TestGetValidatedInstanceIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*cobra.Command) + want *Identifier + wantErr any + }{ + { + name: "instance id success", + setup: func(cmd *cobra.Command) { + cmd.Flags().String(commonInstance.InstanceIdFlag, "", "") + _ = cmd.Flags().Set(commonInstance.InstanceIdFlag, "edgesvc01") + }, + want: &Identifier{Flag: commonInstance.InstanceIdFlag, Value: "edgesvc01"}, + }, + { + name: "display name success", + setup: func(cmd *cobra.Command) { + cmd.Flags().String(commonInstance.DisplayNameFlag, "", "") + _ = cmd.Flags().Set(commonInstance.DisplayNameFlag, "edge01") + }, + want: &Identifier{Flag: commonInstance.DisplayNameFlag, Value: "edge01"}, + }, + { + name: "instance id validation error", + setup: func(cmd *cobra.Command) { + cmd.Flags().String(commonInstance.InstanceIdFlag, "", "") + _ = cmd.Flags().Set(commonInstance.InstanceIdFlag, "id") + }, + wantErr: "too short", + }, + { + name: "display name validation error", + setup: func(cmd *cobra.Command) { + cmd.Flags().String(commonInstance.DisplayNameFlag, "", "") + _ = cmd.Flags().Set(commonInstance.DisplayNameFlag, "x") + }, + wantErr: "too short", + }, + { + name: "no identifier", + setup: func(_ *cobra.Command) {}, + wantErr: &commonErr.NoIdentifierError{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + printer := print.NewPrinter() + cmd := &cobra.Command{Use: "test"} + tt.setup(cmd) + + got, err := GetValidatedInstanceIdentifier(printer, cmd) + if !testUtils.AssertError(t, err, tt.wantErr) { + return + } + if tt.want != nil { + testUtils.AssertValue(t, got, tt.want) + } + }) + } +} diff --git a/internal/pkg/services/git/client/client.go b/internal/pkg/services/git/client/client.go new file mode 100644 index 000000000..3fc5b21b1 --- /dev/null +++ b/internal/pkg/services/git/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*git.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.GitCustomEndpointKey), false, genericclient.CreateApiClient[*git.APIClient](git.NewAPIClient)) +} diff --git a/internal/pkg/services/git/utils/utils.go b/internal/pkg/services/git/utils/utils.go new file mode 100644 index 000000000..3a875c920 --- /dev/null +++ b/internal/pkg/services/git/utils/utils.go @@ -0,0 +1,23 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/git" +) + +type GitClient interface { + GetInstanceExecute(ctx context.Context, projectId string, instanceId string) (*git.Instance, error) +} + +func GetInstanceName(ctx context.Context, apiClient GitClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get instance: %w", err) + } + if resp.Name == nil { + return "", nil + } + return *resp.Name, nil +} diff --git a/internal/pkg/services/git/utils/utils_test.go b/internal/pkg/services/git/utils/utils_test.go new file mode 100644 index 000000000..ed388263f --- /dev/null +++ b/internal/pkg/services/git/utils/utils_test.go @@ -0,0 +1,67 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-sdk-go/services/git" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type GitClientMocked struct { + GetInstanceFails bool + GetInstanceResp *git.Instance +} + +func (m *GitClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*git.Instance, error) { + if m.GetInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.GetInstanceResp, nil +} + +func TestGetinstanceName(t *testing.T) { + tests := []struct { + name string + instanceResp *git.Instance + instanceErr bool + want string + wantErr bool + }{ + { + name: "successful retrieval", + instanceResp: &git.Instance{Name: utils.Ptr("test-instance")}, + want: "test-instance", + wantErr: false, + }, + { + name: "error on retrieval", + instanceErr: true, + wantErr: true, + }, + { + name: "nil name", + instanceErr: false, + instanceResp: &git.Instance{}, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &GitClientMocked{ + GetInstanceFails: tt.instanceErr, + GetInstanceResp: tt.instanceResp, + } + got, err := GetInstanceName(context.Background(), client, "", "") + if (err != nil) != tt.wantErr { + t.Errorf("GetInstanceName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetInstanceName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/pkg/services/iaas/client/client.go b/internal/pkg/services/iaas/client/client.go index 4f37168e7..a49f04b7c 100644 --- a/internal/pkg/services/iaas/client/client.go +++ b/internal/pkg/services/iaas/client/client.go @@ -1,47 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) -func ConfigureClient(p *print.Printer) (*iaas.APIClient, error) { - var err error - var apiClient *iaas.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.IaaSCustomEndpointKey) - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } else { - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = iaas.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*iaas.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.IaaSCustomEndpointKey), false, genericclient.CreateApiClient[*iaas.APIClient](iaas.NewAPIClient)) } diff --git a/internal/pkg/services/iaas/utils/utils.go b/internal/pkg/services/iaas/utils/utils.go index b3456d254..8d04cd831 100644 --- a/internal/pkg/services/iaas/utils/utils.go +++ b/internal/pkg/services/iaas/utils/utils.go @@ -2,27 +2,37 @@ package utils import ( "context" + "errors" "fmt" "github.com/stackitcloud/stackit-sdk-go/services/iaas" ) +var ( + ErrResponseNil = errors.New("response is nil") + ErrNameNil = errors.New("name is nil") + ErrItemsNil = errors.New("items is nil") +) + type IaaSClient interface { - GetSecurityGroupRuleExecute(ctx context.Context, projectId, securityGroupRuleId, securityGroupId string) (*iaas.SecurityGroupRule, error) - GetSecurityGroupExecute(ctx context.Context, projectId, securityGroupId string) (*iaas.SecurityGroup, error) - GetPublicIPExecute(ctx context.Context, projectId, publicIpId string) (*iaas.PublicIp, error) - GetServerExecute(ctx context.Context, projectId, serverId string) (*iaas.Server, error) - GetVolumeExecute(ctx context.Context, projectId, volumeId string) (*iaas.Volume, error) - GetNetworkExecute(ctx context.Context, projectId, networkId string) (*iaas.Network, error) + GetSecurityGroupRuleExecute(ctx context.Context, projectId, region, securityGroupRuleId, securityGroupId string) (*iaas.SecurityGroupRule, error) + GetSecurityGroupExecute(ctx context.Context, projectId, region, securityGroupId string) (*iaas.SecurityGroup, error) + GetPublicIPExecute(ctx context.Context, projectId, region, publicIpId string) (*iaas.PublicIp, error) + GetServerExecute(ctx context.Context, projectId, region, serverId string) (*iaas.Server, error) + GetVolumeExecute(ctx context.Context, projectId, region, volumeId string) (*iaas.Volume, error) + GetNetworkExecute(ctx context.Context, projectId, region, networkId string) (*iaas.Network, error) + GetRoutingTableOfAreaExecute(ctx context.Context, organizationId, areaId, region, routingTableId string) (*iaas.RoutingTable, error) GetNetworkAreaExecute(ctx context.Context, organizationId, areaId string) (*iaas.NetworkArea, error) ListNetworkAreaProjectsExecute(ctx context.Context, organizationId, areaId string) (*iaas.ProjectListResponse, error) - GetNetworkAreaRangeExecute(ctx context.Context, organizationId, areaId, networkRangeId string) (*iaas.NetworkRange, error) - GetImageExecute(ctx context.Context, projectId string, imageId string) (*iaas.Image, error) - GetAffinityGroupExecute(ctx context.Context, projectId string, affinityGroupId string) (*iaas.AffinityGroup, error) + GetNetworkAreaRangeExecute(ctx context.Context, organizationId, areaId, region, networkRangeId string) (*iaas.NetworkRange, error) + GetImageExecute(ctx context.Context, projectId, region, imageId string) (*iaas.Image, error) + GetAffinityGroupExecute(ctx context.Context, projectId, region, affinityGroupId string) (*iaas.AffinityGroup, error) + GetSnapshotExecute(ctx context.Context, projectId, region, snapshotId string) (*iaas.Snapshot, error) + GetBackupExecute(ctx context.Context, projectId, region, backupId string) (*iaas.Backup, error) } -func GetSecurityGroupRuleName(ctx context.Context, apiClient IaaSClient, projectId, securityGroupRuleId, securityGroupId string) (string, error) { - resp, err := apiClient.GetSecurityGroupRuleExecute(ctx, projectId, securityGroupRuleId, securityGroupId) +func GetSecurityGroupRuleName(ctx context.Context, apiClient IaaSClient, projectId, region, securityGroupRuleId, securityGroupId string) (string, error) { + resp, err := apiClient.GetSecurityGroupRuleExecute(ctx, projectId, region, securityGroupRuleId, securityGroupId) if err != nil { return "", fmt.Errorf("get security group rule: %w", err) } @@ -30,16 +40,20 @@ func GetSecurityGroupRuleName(ctx context.Context, apiClient IaaSClient, project return securityGroupRuleName, nil } -func GetSecurityGroupName(ctx context.Context, apiClient IaaSClient, projectId, securityGroupId string) (string, error) { - resp, err := apiClient.GetSecurityGroupExecute(ctx, projectId, securityGroupId) +func GetSecurityGroupName(ctx context.Context, apiClient IaaSClient, projectId, region, securityGroupId string) (string, error) { + resp, err := apiClient.GetSecurityGroupExecute(ctx, projectId, region, securityGroupId) if err != nil { return "", fmt.Errorf("get security group: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil } return *resp.Name, nil } -func GetPublicIP(ctx context.Context, apiClient IaaSClient, projectId, publicIpId string) (ip, associatedResource string, err error) { - resp, err := apiClient.GetPublicIPExecute(ctx, projectId, publicIpId) +func GetPublicIP(ctx context.Context, apiClient IaaSClient, projectId, region, publicIpId string) (ip, associatedResource string, err error) { + resp, err := apiClient.GetPublicIPExecute(ctx, projectId, region, publicIpId) if err != nil { return "", "", fmt.Errorf("get public ip: %w", err) } @@ -50,26 +64,46 @@ func GetPublicIP(ctx context.Context, apiClient IaaSClient, projectId, publicIpI return *resp.Ip, associatedResourceId, nil } -func GetServerName(ctx context.Context, apiClient IaaSClient, projectId, serverId string) (string, error) { - resp, err := apiClient.GetServerExecute(ctx, projectId, serverId) +func GetServerName(ctx context.Context, apiClient IaaSClient, projectId, region, serverId string) (string, error) { + resp, err := apiClient.GetServerExecute(ctx, projectId, region, serverId) if err != nil { return "", fmt.Errorf("get server: %w", err) } return *resp.Name, nil } -func GetVolumeName(ctx context.Context, apiClient IaaSClient, projectId, volumeId string) (string, error) { - resp, err := apiClient.GetVolumeExecute(ctx, projectId, volumeId) +func GetVolumeName(ctx context.Context, apiClient IaaSClient, projectId, region, volumeId string) (string, error) { + resp, err := apiClient.GetVolumeExecute(ctx, projectId, region, volumeId) if err != nil { return "", fmt.Errorf("get volume: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil } return *resp.Name, nil } -func GetNetworkName(ctx context.Context, apiClient IaaSClient, projectId, networkId string) (string, error) { - resp, err := apiClient.GetNetworkExecute(ctx, projectId, networkId) +func GetNetworkName(ctx context.Context, apiClient IaaSClient, projectId, region, networkId string) (string, error) { + resp, err := apiClient.GetNetworkExecute(ctx, projectId, region, networkId) if err != nil { return "", fmt.Errorf("get network: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil + } + return *resp.Name, nil +} + +func GetRoutingTableOfAreaName(ctx context.Context, apiClient IaaSClient, organizationId, areaId, region, routingTableId string) (string, error) { + resp, err := apiClient.GetRoutingTableOfAreaExecute(ctx, organizationId, areaId, region, routingTableId) + if err != nil { + return "", fmt.Errorf("get routing-table: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil } return *resp.Name, nil } @@ -78,6 +112,10 @@ func GetNetworkAreaName(ctx context.Context, apiClient IaaSClient, organizationI resp, err := apiClient.GetNetworkAreaExecute(ctx, organizationId, areaId) if err != nil { return "", fmt.Errorf("get network area: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil } return *resp.Name, nil } @@ -86,12 +124,16 @@ func ListAttachedProjects(ctx context.Context, apiClient IaaSClient, organizatio resp, err := apiClient.ListNetworkAreaProjectsExecute(ctx, organizationId, areaId) if err != nil { return nil, fmt.Errorf("list network area attached projects: %w", err) + } else if resp == nil { + return nil, ErrResponseNil + } else if resp.Items == nil { + return nil, ErrItemsNil } return *resp.Items, nil } -func GetNetworkRangePrefix(ctx context.Context, apiClient IaaSClient, organizationId, areaId, networkRangeId string) (string, error) { - resp, err := apiClient.GetNetworkAreaRangeExecute(ctx, organizationId, areaId, networkRangeId) +func GetNetworkRangePrefix(ctx context.Context, apiClient IaaSClient, organizationId, areaId, region, networkRangeId string) (string, error) { + resp, err := apiClient.GetNetworkAreaRangeExecute(ctx, organizationId, areaId, region, networkRangeId) if err != nil { return "", fmt.Errorf("get network range: %w", err) } @@ -100,10 +142,47 @@ func GetNetworkRangePrefix(ctx context.Context, apiClient IaaSClient, organizati // GetRouteFromAPIResponse returns the static route from the API response that matches the prefix and nexthop // This works because static routes are unique by prefix and nexthop -func GetRouteFromAPIResponse(prefix, nexthop string, routes *[]iaas.Route) (iaas.Route, error) { +func GetRouteFromAPIResponse(destination, nexthop string, routes *[]iaas.Route) (iaas.Route, error) { for _, route := range *routes { - if *route.Prefix == prefix && *route.Nexthop == nexthop { - return route, nil + // Check if destination matches + if dest := route.Destination; dest != nil { + match := false + if destV4 := dest.DestinationCIDRv4; destV4 != nil { + if destV4.Value != nil && *destV4.Value == destination { + match = true + } + } else if destV6 := dest.DestinationCIDRv6; destV6 != nil { + if destV6.Value != nil && *destV6.Value == destination { + match = true + } + } + if !match { + continue + } + } + // Check if nexthop matches + if routeNexthop := route.Nexthop; routeNexthop != nil { + match := false + if nexthopIPv4 := routeNexthop.NexthopIPv4; nexthopIPv4 != nil { + if nexthopIPv4.Value != nil && *nexthopIPv4.Value == nexthop { + match = true + } + } else if nexthopIPv6 := routeNexthop.NexthopIPv6; nexthopIPv6 != nil { + if nexthopIPv6.Value != nil && *nexthopIPv6.Value == nexthop { + match = true + } + } else if nexthopInternet := routeNexthop.NexthopInternet; nexthopInternet != nil { + if nexthopInternet.Type != nil && *nexthopInternet.Type == nexthop { + match = true + } + } else if nexthopBlackhole := routeNexthop.NexthopBlackhole; nexthopBlackhole != nil { + if nexthopBlackhole.Type != nil && *nexthopBlackhole.Type == nexthop { + match = true + } + } + if match { + return route, nil + } } } return iaas.Route{}, fmt.Errorf("new static route not found in API response") @@ -120,24 +199,49 @@ func GetNetworkRangeFromAPIResponse(prefix string, networkRanges *[]iaas.Network return iaas.NetworkRange{}, fmt.Errorf("new network range not found in API response") } -func GetImageName(ctx context.Context, apiClient IaaSClient, projectId, imageId string) (string, error) { - resp, err := apiClient.GetImageExecute(ctx, projectId, imageId) +func GetImageName(ctx context.Context, apiClient IaaSClient, projectId, region, imageId string) (string, error) { + resp, err := apiClient.GetImageExecute(ctx, projectId, region, imageId) if err != nil { return "", fmt.Errorf("get image: %w", err) - } - if resp.Name == nil { - return "", nil + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil } return *resp.Name, nil } -func GetAffinityGroupName(ctx context.Context, apiClient IaaSClient, projectId, affinityGroupId string) (string, error) { - resp, err := apiClient.GetAffinityGroupExecute(ctx, projectId, affinityGroupId) +func GetAffinityGroupName(ctx context.Context, apiClient IaaSClient, projectId, region, affinityGroupId string) (string, error) { + resp, err := apiClient.GetAffinityGroupExecute(ctx, projectId, region, affinityGroupId) if err != nil { return "", fmt.Errorf("get affinity group: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil } - if resp.Name == nil { - return "", nil + return *resp.Name, nil +} + +func GetSnapshotName(ctx context.Context, apiClient IaaSClient, projectId, region, snapshotId string) (string, error) { + resp, err := apiClient.GetSnapshotExecute(ctx, projectId, region, snapshotId) + if err != nil { + return "", fmt.Errorf("get snapshot: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.Name == nil { + return "", ErrNameNil } return *resp.Name, nil } + +func GetBackupName(ctx context.Context, apiClient IaaSClient, projectId, region, backupId string) (string, error) { + resp, err := apiClient.GetBackupExecute(ctx, projectId, region, backupId) + if err != nil { + return backupId, fmt.Errorf("get backup: %w", err) + } + if resp != nil && resp.Name != nil { + return *resp.Name, nil + } + return backupId, nil +} diff --git a/internal/pkg/services/iaas/utils/utils_test.go b/internal/pkg/services/iaas/utils/utils_test.go index d62dac35c..61ec9b02d 100644 --- a/internal/pkg/services/iaas/utils/utils_test.go +++ b/internal/pkg/services/iaas/utils/utils_test.go @@ -6,84 +6,100 @@ import ( "reflect" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) +var _ IaaSClient = &IaaSClientMocked{} + type IaaSClientMocked struct { - GetSecurityGroupRuleFails bool - GetSecurityGroupRuleResp *iaas.SecurityGroupRule - GetSecurityGroupFails bool - GetSecurityGroupResp *iaas.SecurityGroup - GetPublicIpFails bool - GetPublicIpResp *iaas.PublicIp - GetServerFails bool - GetServerResp *iaas.Server - GetVolumeFails bool - GetVolumeResp *iaas.Volume - GetNetworkFails bool - GetNetworkResp *iaas.Network - GetNetworkAreaFails bool - GetNetworkAreaResp *iaas.NetworkArea - GetAttachedProjectsFails bool - GetAttachedProjectsResp *iaas.ProjectListResponse - GetNetworkAreaRangeFails bool - GetNetworkAreaRangeResp *iaas.NetworkRange - GetImageFails bool - GetImageResp *iaas.Image - GetAffinityGroupsFails bool - GetAffinityGroupResp *iaas.AffinityGroup + GetSecurityGroupRuleFails bool + GetSecurityGroupRuleResp *iaas.SecurityGroupRule + GetSecurityGroupFails bool + GetSecurityGroupResp *iaas.SecurityGroup + GetPublicIpFails bool + GetPublicIpResp *iaas.PublicIp + GetServerFails bool + GetServerResp *iaas.Server + GetVolumeFails bool + GetVolumeResp *iaas.Volume + GetNetworkFails bool + GetNetworkResp *iaas.Network + GetRoutingTableOfAreaFails bool + GetRoutingTableOfAreaResp *iaas.RoutingTable + GetNetworkAreaFails bool + GetNetworkAreaResp *iaas.NetworkArea + GetAttachedProjectsFails bool + GetAttachedProjectsResp *iaas.ProjectListResponse + GetNetworkAreaRangeFails bool + GetNetworkAreaRangeResp *iaas.NetworkRange + GetImageFails bool + GetImageResp *iaas.Image + GetAffinityGroupsFails bool + GetAffinityGroupResp *iaas.AffinityGroup + GetBackupFails bool + GetBackupResp *iaas.Backup + GetSnapshotFails bool + GetSnapshotResp *iaas.Snapshot } -func (m *IaaSClientMocked) GetAffinityGroupExecute(_ context.Context, _, _ string) (*iaas.AffinityGroup, error) { +func (m *IaaSClientMocked) GetAffinityGroupExecute(_ context.Context, _, _, _ string) (*iaas.AffinityGroup, error) { if m.GetAffinityGroupsFails { return nil, fmt.Errorf("could not get affinity groups") } return m.GetAffinityGroupResp, nil } -func (m *IaaSClientMocked) GetSecurityGroupRuleExecute(_ context.Context, _, _, _ string) (*iaas.SecurityGroupRule, error) { +func (m *IaaSClientMocked) GetSecurityGroupRuleExecute(_ context.Context, _, _, _, _ string) (*iaas.SecurityGroupRule, error) { if m.GetSecurityGroupRuleFails { return nil, fmt.Errorf("could not get security group rule") } return m.GetSecurityGroupRuleResp, nil } -func (m *IaaSClientMocked) GetSecurityGroupExecute(_ context.Context, _, _ string) (*iaas.SecurityGroup, error) { +func (m *IaaSClientMocked) GetSecurityGroupExecute(_ context.Context, _, _, _ string) (*iaas.SecurityGroup, error) { if m.GetSecurityGroupFails { return nil, fmt.Errorf("could not get security group") } return m.GetSecurityGroupResp, nil } -func (m *IaaSClientMocked) GetPublicIPExecute(_ context.Context, _, _ string) (*iaas.PublicIp, error) { +func (m *IaaSClientMocked) GetPublicIPExecute(_ context.Context, _, _, _ string) (*iaas.PublicIp, error) { if m.GetPublicIpFails { return nil, fmt.Errorf("could not get public ip") } return m.GetPublicIpResp, nil } -func (m *IaaSClientMocked) GetServerExecute(_ context.Context, _, _ string) (*iaas.Server, error) { +func (m *IaaSClientMocked) GetServerExecute(_ context.Context, _, _, _ string) (*iaas.Server, error) { if m.GetServerFails { return nil, fmt.Errorf("could not get server") } return m.GetServerResp, nil } -func (m *IaaSClientMocked) GetVolumeExecute(_ context.Context, _, _ string) (*iaas.Volume, error) { +func (m *IaaSClientMocked) GetVolumeExecute(_ context.Context, _, _, _ string) (*iaas.Volume, error) { if m.GetVolumeFails { return nil, fmt.Errorf("could not get volume") } return m.GetVolumeResp, nil } -func (m *IaaSClientMocked) GetNetworkExecute(_ context.Context, _, _ string) (*iaas.Network, error) { +func (m *IaaSClientMocked) GetNetworkExecute(_ context.Context, _, _, _ string) (*iaas.Network, error) { if m.GetNetworkFails { return nil, fmt.Errorf("could not get network") } return m.GetNetworkResp, nil } +func (m *IaaSClientMocked) GetRoutingTableOfAreaExecute(_ context.Context, _, _, _, _ string) (*iaas.RoutingTable, error) { + if m.GetRoutingTableOfAreaFails { + return nil, fmt.Errorf("could not get routing table") + } + return m.GetRoutingTableOfAreaResp, nil +} + func (m *IaaSClientMocked) GetNetworkAreaExecute(_ context.Context, _, _ string) (*iaas.NetworkArea, error) { if m.GetNetworkAreaFails { return nil, fmt.Errorf("could not get network area") @@ -98,20 +114,33 @@ func (m *IaaSClientMocked) ListNetworkAreaProjectsExecute(_ context.Context, _, return m.GetAttachedProjectsResp, nil } -func (m *IaaSClientMocked) GetNetworkAreaRangeExecute(_ context.Context, _, _, _ string) (*iaas.NetworkRange, error) { +func (m *IaaSClientMocked) GetNetworkAreaRangeExecute(_ context.Context, _, _, _, _ string) (*iaas.NetworkRange, error) { if m.GetNetworkAreaRangeFails { return nil, fmt.Errorf("could not get network range") } return m.GetNetworkAreaRangeResp, nil } -func (m *IaaSClientMocked) GetImageExecute(_ context.Context, _, _ string) (*iaas.Image, error) { +func (m *IaaSClientMocked) GetImageExecute(_ context.Context, _, _, _ string) (*iaas.Image, error) { if m.GetImageFails { return nil, fmt.Errorf("could not get image") } return m.GetImageResp, nil } +func (m *IaaSClientMocked) GetBackupExecute(_ context.Context, _, _, _ string) (*iaas.Backup, error) { + if m.GetBackupFails { + return nil, fmt.Errorf("could not get backup") + } + return m.GetBackupResp, nil +} + +func (m *IaaSClientMocked) GetSnapshotExecute(_ context.Context, _, _, _ string) (*iaas.Snapshot, error) { + if m.GetSnapshotFails { + return nil, fmt.Errorf("could not get snapshot") + } + return m.GetSnapshotResp, nil +} func TestGetSecurityGroupRuleName(t *testing.T) { type args struct { getInstanceFails bool @@ -147,7 +176,7 @@ func TestGetSecurityGroupRuleName(t *testing.T) { GetSecurityGroupRuleFails: tt.args.getInstanceFails, GetSecurityGroupRuleResp: tt.args.getInstanceResp, } - got, err := GetSecurityGroupRuleName(context.Background(), m, "", "", "") + got, err := GetSecurityGroupRuleName(context.Background(), m, "", "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetSecurityGroupRuleName() error = %v, wantErr %v", err, tt.wantErr) return @@ -186,6 +215,26 @@ func TestGetSecurityGroupName(t *testing.T) { }, wantErr: true, }, + { + name: "response is nil", + args: args{ + getInstanceResp: nil, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, + { + name: "name in response is nil", + args: args{ + getInstanceResp: &iaas.SecurityGroup{ + Name: nil, + }, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -193,7 +242,7 @@ func TestGetSecurityGroupName(t *testing.T) { GetSecurityGroupFails: tt.args.getInstanceFails, GetSecurityGroupResp: tt.args.getInstanceResp, } - got, err := GetSecurityGroupName(context.Background(), m, "", "") + got, err := GetSecurityGroupName(context.Background(), m, "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetSecurityGroupName() error = %v, wantErr %v", err, tt.wantErr) return @@ -242,7 +291,7 @@ func TestGetPublicIp(t *testing.T) { GetPublicIpFails: tt.args.getPublicIpFails, GetPublicIpResp: tt.args.getPublicIpResp, } - gotPublicIP, gotAssociatedResource, err := GetPublicIP(context.Background(), m, "", "") + gotPublicIP, gotAssociatedResource, err := GetPublicIP(context.Background(), m, "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetPublicIP() error = %v, wantErr %v", err, tt.wantErr) return @@ -291,7 +340,7 @@ func TestGetServerName(t *testing.T) { GetServerFails: tt.args.getInstanceFails, GetServerResp: tt.args.getInstanceResp, } - got, err := GetServerName(context.Background(), m, "", "") + got, err := GetServerName(context.Background(), m, "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetServerName() error = %v, wantErr %v", err, tt.wantErr) return @@ -330,6 +379,26 @@ func TestGetVolumeName(t *testing.T) { }, wantErr: true, }, + { + name: "response is nil", + args: args{ + getInstanceResp: nil, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, + { + name: "name in response is nil", + args: args{ + getInstanceResp: &iaas.Volume{ + Name: nil, + }, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -337,7 +406,7 @@ func TestGetVolumeName(t *testing.T) { GetVolumeFails: tt.args.getInstanceFails, GetVolumeResp: tt.args.getInstanceResp, } - got, err := GetVolumeName(context.Background(), m, "", "") + got, err := GetVolumeName(context.Background(), m, "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetVolumeName() error = %v, wantErr %v", err, tt.wantErr) return @@ -376,6 +445,26 @@ func TestGetNetworkName(t *testing.T) { }, wantErr: true, }, + { + name: "response is nil", + args: args{ + getInstanceResp: nil, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, + { + name: "name in response is nil", + args: args{ + getInstanceResp: &iaas.Network{ + Name: nil, + }, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -383,7 +472,7 @@ func TestGetNetworkName(t *testing.T) { GetNetworkFails: tt.args.getInstanceFails, GetNetworkResp: tt.args.getInstanceResp, } - got, err := GetNetworkName(context.Background(), m, "", "") + got, err := GetNetworkName(context.Background(), m, "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetNetworkName() error = %v, wantErr %v", err, tt.wantErr) return @@ -421,6 +510,27 @@ func TestGetNetworkAreaName(t *testing.T) { getInstanceFails: true, }, wantErr: true, + want: "", + }, + { + name: "response is nil", + args: args{ + getInstanceResp: nil, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, + { + name: "name in response is nil", + args: args{ + getInstanceResp: &iaas.NetworkArea{ + Name: nil, + }, + getInstanceFails: false, + }, + wantErr: true, + want: "", }, } for _, tt := range tests { @@ -521,7 +631,7 @@ func TestGetNetworkRangePrefix(t *testing.T) { GetNetworkAreaRangeFails: tt.args.getNetworkAreaRangeFails, GetNetworkAreaRangeResp: tt.args.getNetworkAreaRangeResp, } - got, err := GetNetworkRangePrefix(context.Background(), m, "", "", "") + got, err := GetNetworkRangePrefix(context.Background(), m, "", "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetNetworkRangePrefix() error = %v, wantErr %v", err, tt.wantErr) return @@ -552,22 +662,210 @@ func TestGetRouteFromAPIResponse(t *testing.T) { nexthop: "1.1.1.1", routes: &[]iaas.Route{ { - Prefix: utils.Ptr("1.1.1.0/24"), - Nexthop: utils.Ptr("1.1.1.1"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("1.1.1.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("1.1.1.1"), + }, + }, }, { - Prefix: utils.Ptr("2.2.2.0/24"), - Nexthop: utils.Ptr("2.2.2.2"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("2.2.2.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("2.2.2.2"), + }, + }, }, { - Prefix: utils.Ptr("3.3.3.0/24"), - Nexthop: utils.Ptr("3.3.3.3"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("3.3.3.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr("blackhole"), + }, + }, + }, + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("4.4.4.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: utils.Ptr("internet"), + }, + }, }, }, }, want: iaas.Route{ - Prefix: utils.Ptr("1.1.1.0/24"), - Nexthop: utils.Ptr("1.1.1.1"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr("cidrv4"), + Value: utils.Ptr("1.1.1.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr("ipv4"), + Value: utils.Ptr("1.1.1.1"), + }, + }, + }, + }, + { + name: "nexthop internet", + args: args{ + prefix: "4.4.4.0/24", + nexthop: "internet", + routes: &[]iaas.Route{ + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("1.1.1.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Value: utils.Ptr("1.1.1.1"), + }, + }, + }, + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("2.2.2.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Value: utils.Ptr("2.2.2.2"), + }, + }, + }, + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("3.3.3.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr("blackhole"), + }, + }, + }, + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("4.4.4.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: utils.Ptr("internet"), + }, + }, + }, + }, + }, + want: iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("4.4.4.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: utils.Ptr("internet"), + }, + }, + }, + }, + { + name: "nexthop backhole", + args: args{ + prefix: "3.3.3.0/24", + nexthop: "blackhole", + routes: &[]iaas.Route{ + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("1.1.1.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Value: utils.Ptr("1.1.1.1"), + }, + }, + }, + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("2.2.2.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Value: utils.Ptr("2.2.2.2"), + }, + }, + }, + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("3.3.3.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr("blackhole"), + }, + }, + }, + { + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("4.4.4.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: utils.Ptr("internet"), + }, + }, + }, + }, + }, + want: iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("3.3.3.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr("blackhole"), + }, + }, }, }, { @@ -577,12 +875,28 @@ func TestGetRouteFromAPIResponse(t *testing.T) { nexthop: "1.1.1.1", routes: &[]iaas.Route{ { - Prefix: utils.Ptr("2.2.2.0/24"), - Nexthop: utils.Ptr("2.2.2.2"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("2.2.2.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Value: utils.Ptr("2.2.2.2"), + }, + }, }, { - Prefix: utils.Ptr("3.3.3.0/24"), - Nexthop: utils.Ptr("3.3.3.3"), + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Value: utils.Ptr("3.3.3.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Value: utils.Ptr("3.3.3.3"), + }, + }, }, }, }, @@ -701,10 +1015,18 @@ func TestGetImageName(t *testing.T) { wantErr: true, }, { - name: "nil name", + name: "response is nil", + imageErr: false, + imageResp: nil, + want: "", + wantErr: true, + }, + { + name: "name in response is nil", imageErr: false, - imageResp: &iaas.Image{}, + imageResp: &iaas.Image{Name: nil}, want: "", + wantErr: true, }, } for _, tt := range tests { @@ -713,7 +1035,7 @@ func TestGetImageName(t *testing.T) { GetImageFails: tt.imageErr, GetImageResp: tt.imageResp, } - got, err := GetImageName(context.Background(), client, "", "") + got, err := GetImageName(context.Background(), client, "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetImageName() error = %v, wantErr %v", err, tt.wantErr) return @@ -745,10 +1067,22 @@ func TestGetAffinityGroupName(t *testing.T) { wantErr: true, }, { - name: "nil affinity group name", - affinityErr: false, - affinityResp: &iaas.AffinityGroup{}, - want: "", + name: "response is nil", + affinityErr: false, + affinityResp: &iaas.AffinityGroup{ + Name: nil, + }, + want: "", + wantErr: true, + }, + { + name: "affinity group name in response is nil", + affinityErr: false, + affinityResp: &iaas.AffinityGroup{ + Name: nil, + }, + want: "", + wantErr: true, }, } for _, tt := range tests { @@ -758,7 +1092,7 @@ func TestGetAffinityGroupName(t *testing.T) { GetAffinityGroupsFails: tt.affinityErr, GetAffinityGroupResp: tt.affinityResp, } - got, err := GetAffinityGroupName(ctx, client, "", "") + got, err := GetAffinityGroupName(ctx, client, "", "", "") if (err != nil) != tt.wantErr { t.Errorf("GetAffinityGroupName() error = %v, wantErr %v", err, tt.wantErr) return @@ -769,3 +1103,71 @@ func TestGetAffinityGroupName(t *testing.T) { }) } } + +func TestGetRoutingTableOfAreaName(t *testing.T) { + type args struct { + getInstanceFails bool + getInstanceResp *iaas.RoutingTable + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "base", + args: args{ + getInstanceResp: &iaas.RoutingTable{ + Name: utils.Ptr("test"), + }, + }, + want: "test", + }, + { + name: "get routing table fails", + args: args{ + getInstanceFails: true, + }, + wantErr: true, + want: "", + }, + { + name: "response is nil", + args: args{ + getInstanceResp: nil, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, + { + name: "name in response is nil", + args: args{ + getInstanceResp: &iaas.RoutingTable{ + Name: nil, + }, + getInstanceFails: false, + }, + wantErr: true, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &IaaSClientMocked{ + GetRoutingTableOfAreaFails: tt.args.getInstanceFails, + GetRoutingTableOfAreaResp: tt.args.getInstanceResp, + } + + got, err := GetRoutingTableOfAreaName(context.Background(), m, "", "", "", "") + if (err != nil) != tt.wantErr { + t.Errorf("GetRoutingTableOfAreaName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GetRoutingTableOfAreaName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/pkg/services/intake/client/client.go b/internal/pkg/services/intake/client/client.go new file mode 100644 index 000000000..2a0e89400 --- /dev/null +++ b/internal/pkg/services/intake/client/client.go @@ -0,0 +1,15 @@ +package client + +import ( + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-sdk-go/services/intake" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +// ConfigureClient creates and configures a new Intake API client +func ConfigureClient(p *print.Printer, cliVersion string) (*intake.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.IntakeCustomEndpointKey), true, genericclient.CreateApiClient[*intake.APIClient](intake.NewAPIClient)) +} diff --git a/internal/pkg/services/kms/client/client.go b/internal/pkg/services/kms/client/client.go new file mode 100644 index 000000000..ecb2111a2 --- /dev/null +++ b/internal/pkg/services/kms/client/client.go @@ -0,0 +1,14 @@ +package client + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" + "github.com/stackitcloud/stackit-sdk-go/services/kms" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*kms.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.KMSCustomEndpointKey), false, genericclient.CreateApiClient[*kms.APIClient](kms.NewAPIClient)) +} diff --git a/internal/pkg/services/kms/utils/utils.go b/internal/pkg/services/kms/utils/utils.go new file mode 100644 index 000000000..5630e27d6 --- /dev/null +++ b/internal/pkg/services/kms/utils/utils.go @@ -0,0 +1,67 @@ +package utils + +import ( + "context" + "fmt" + "time" + + "github.com/stackitcloud/stackit-sdk-go/services/kms" +) + +type KMSClient interface { + GetKeyExecute(ctx context.Context, projectId string, regionId string, keyRingId string, keyId string) (*kms.Key, error) + GetKeyRingExecute(ctx context.Context, projectId string, regionId string, keyRingId string) (*kms.KeyRing, error) + GetWrappingKeyExecute(ctx context.Context, projectId string, regionId string, keyRingId string, wrappingKeyId string) (*kms.WrappingKey, error) +} + +func GetKeyName(ctx context.Context, apiClient KMSClient, projectId, region, keyRingId, keyId string) (string, error) { + resp, err := apiClient.GetKeyExecute(ctx, projectId, region, keyRingId, keyId) + if err != nil { + return "", fmt.Errorf("get KMS Key: %w", err) + } + + if resp == nil || resp.DisplayName == nil { + return "", fmt.Errorf("response is nil / empty") + } + + return *resp.DisplayName, nil +} + +func GetKeyDeletionDate(ctx context.Context, apiClient KMSClient, projectId, region, keyRingId, keyId string) (time.Time, error) { + resp, err := apiClient.GetKeyExecute(ctx, projectId, region, keyRingId, keyId) + if err != nil { + return time.Now(), fmt.Errorf("get KMS Key: %w", err) + } + + if resp == nil || resp.DeletionDate == nil { + return time.Time{}, fmt.Errorf("response is nil / empty") + } + + return *resp.DeletionDate, nil +} + +func GetKeyRingName(ctx context.Context, apiClient KMSClient, projectId, id, region string) (string, error) { + resp, err := apiClient.GetKeyRingExecute(ctx, projectId, region, id) + if err != nil { + return "", fmt.Errorf("get KMS key ring: %w", err) + } + + if resp == nil || resp.DisplayName == nil { + return "", fmt.Errorf("response is nil / empty") + } + + return *resp.DisplayName, nil +} + +func GetWrappingKeyName(ctx context.Context, apiClient KMSClient, projectId, region, keyRingId, wrappingKeyId string) (string, error) { + resp, err := apiClient.GetWrappingKeyExecute(ctx, projectId, region, keyRingId, wrappingKeyId) + if err != nil { + return "", fmt.Errorf("get KMS Wrapping Key: %w", err) + } + + if resp == nil || resp.DisplayName == nil { + return "", fmt.Errorf("response is nil / empty") + } + + return *resp.DisplayName, nil +} diff --git a/internal/pkg/services/kms/utils/utils_test.go b/internal/pkg/services/kms/utils/utils_test.go new file mode 100644 index 000000000..339cb2d3a --- /dev/null +++ b/internal/pkg/services/kms/utils/utils_test.go @@ -0,0 +1,257 @@ +package utils + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/kms" +) + +var ( + testProjectId = uuid.NewString() + testKeyRingId = uuid.NewString() + testKeyId = uuid.NewString() + testWrappingKeyId = uuid.NewString() +) + +const ( + testRegion = "eu01" + testKeyName = "my-test-key" + testKeyRingName = "my-key-ring" + testWrappingKeyName = "my-wrapping-key" +) + +type kmsClientMocked struct { + getKeyFails bool + getKeyResp *kms.Key + getKeyRingFails bool + getKeyRingResp *kms.KeyRing + getWrappingKeyFails bool + getWrappingKeyResp *kms.WrappingKey +} + +// Implement the KMSClient interface methods for the mock. +func (m *kmsClientMocked) GetKeyExecute(_ context.Context, _, _, _, _ string) (*kms.Key, error) { + if m.getKeyFails { + return nil, fmt.Errorf("could not get key") + } + return m.getKeyResp, nil +} + +func (m *kmsClientMocked) GetKeyRingExecute(_ context.Context, _, _, _ string) (*kms.KeyRing, error) { + if m.getKeyRingFails { + return nil, fmt.Errorf("could not get key ring") + } + return m.getKeyRingResp, nil +} + +func (m *kmsClientMocked) GetWrappingKeyExecute(_ context.Context, _, _, _, _ string) (*kms.WrappingKey, error) { + if m.getWrappingKeyFails { + return nil, fmt.Errorf("could not get wrapping key") + } + return m.getWrappingKeyResp, nil +} + +func TestGetKeyName(t *testing.T) { + keyName := testKeyName + + tests := []struct { + description string + getKeyFails bool + getKeyResp *kms.Key + isValid bool + expectedOutput string + }{ + { + description: "base", + getKeyResp: &kms.Key{ + DisplayName: &keyName, + }, + isValid: true, + expectedOutput: testKeyName, + }, + { + description: "get key fails", + getKeyFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &kmsClientMocked{ + getKeyFails: tt.getKeyFails, + getKeyResp: tt.getKeyResp, + } + + output, err := GetKeyName(context.Background(), client, testProjectId, testRegion, testKeyRingId, testKeyId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input: %v", err) + } + 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 %q, got %q", tt.expectedOutput, output) + } + }) + } +} + +// TestGetKeyDeletionDate tests the GetKeyDeletionDate function. +func TestGetKeyDeletionDate(t *testing.T) { + mockTime := time.Date(2025, 8, 20, 0, 0, 0, 0, time.UTC) + + tests := []struct { + description string + getKeyFails bool + getKeyResp *kms.Key + isValid bool + expectedOutput time.Time + }{ + { + description: "base", + getKeyResp: &kms.Key{ + DeletionDate: &mockTime, + }, + isValid: true, + expectedOutput: mockTime, + }, + { + description: "get key fails", + getKeyFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &kmsClientMocked{ + getKeyFails: tt.getKeyFails, + getKeyResp: tt.getKeyResp, + } + + output, err := GetKeyDeletionDate(context.Background(), client, testProjectId, testRegion, testKeyRingId, testKeyId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input: %v", err) + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if !output.Equal(tt.expectedOutput) { + t.Errorf("expected output to be %v, got %v", tt.expectedOutput, output) + } + }) + } +} + +// TestGetKeyRingName tests the GetKeyRingName function. +func TestGetKeyRingName(t *testing.T) { + keyRingName := testKeyRingName + + tests := []struct { + description string + getKeyRingFails bool + getKeyRingResp *kms.KeyRing + isValid bool + expectedOutput string + }{ + { + description: "base", + getKeyRingResp: &kms.KeyRing{ + DisplayName: &keyRingName, + }, + isValid: true, + expectedOutput: testKeyRingName, + }, + { + description: "get key ring fails", + getKeyRingFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &kmsClientMocked{ + getKeyRingFails: tt.getKeyRingFails, + getKeyRingResp: tt.getKeyRingResp, + } + + output, err := GetKeyRingName(context.Background(), client, testProjectId, testKeyRingId, testRegion) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input: %v", err) + } + 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 %q, got %q", tt.expectedOutput, output) + } + }) + } +} + +func TestGetWrappingKeyName(t *testing.T) { + wrappingKeyName := testWrappingKeyName + tests := []struct { + description string + getWrappingKeyFails bool + getWrappingKeyResp *kms.WrappingKey + isValid bool + expectedOutput string + }{ + { + description: "base", + getWrappingKeyResp: &kms.WrappingKey{ + DisplayName: &wrappingKeyName, + }, + isValid: true, + expectedOutput: testWrappingKeyName, + }, + { + description: "get wrapping key fails", + getWrappingKeyFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &kmsClientMocked{ + getWrappingKeyFails: tt.getWrappingKeyFails, + getWrappingKeyResp: tt.getWrappingKeyResp, + } + + output, err := GetWrappingKeyName(context.Background(), client, testProjectId, testRegion, testKeyRingId, testWrappingKeyId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input: %v", err) + } + 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 %q, got %q", tt.expectedOutput, output) + } + }) + } +} diff --git a/internal/pkg/services/load-balancer/client/client.go b/internal/pkg/services/load-balancer/client/client.go index 94d0b0a17..7234c4f88 100644 --- a/internal/pkg/services/load-balancer/client/client.go +++ b/internal/pkg/services/load-balancer/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer" ) -func ConfigureClient(p *print.Printer) (*loadbalancer.APIClient, error) { - var err error - var apiClient *loadbalancer.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.LoadBalancerCustomEndpointKey) - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } else { - cfgOptions = append(cfgOptions, authCfgOption) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = loadbalancer.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*loadbalancer.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.LoadBalancerCustomEndpointKey), false, genericclient.CreateApiClient[*loadbalancer.APIClient](loadbalancer.NewAPIClient)) } diff --git a/internal/pkg/services/load-balancer/utils/utils.go b/internal/pkg/services/load-balancer/utils/utils.go index 4bc13db61..1a5ba6076 100644 --- a/internal/pkg/services/load-balancer/utils/utils.go +++ b/internal/pkg/services/load-balancer/utils/utils.go @@ -159,7 +159,9 @@ func GetUsedObsCredentials(ctx context.Context, apiClient LoadBalancerClient, al } var usedCredentialsRefs []string - for _, loadBalancer := range *loadBalancers.LoadBalancers { + for i := range *loadBalancers.LoadBalancers { + loadBalancer := &(*loadBalancers.LoadBalancers)[i] + if loadBalancer.Options == nil || loadBalancer.Options.Observability == nil { continue } diff --git a/internal/pkg/services/load-balancer/utils/utils_test.go b/internal/pkg/services/load-balancer/utils/utils_test.go index e33fd75b8..5941b2c93 100644 --- a/internal/pkg/services/load-balancer/utils/utils_test.go +++ b/internal/pkg/services/load-balancer/utils/utils_test.go @@ -56,7 +56,7 @@ func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _ } func (m *loadBalancerClientMocked) UpdateTargetPool(_ context.Context, _, _, _, _ string) loadbalancer.ApiUpdateTargetPoolRequest { - return loadbalancer.ApiUpdateTargetPoolRequest{} + return loadbalancer.UpdateTargetPoolRequest{} } func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer.LoadBalancer { diff --git a/internal/pkg/services/logme/client/client.go b/internal/pkg/services/logme/client/client.go index fb3ba17a0..f65bcfb6a 100644 --- a/internal/pkg/services/logme/client/client.go +++ b/internal/pkg/services/logme/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/logme" ) -func ConfigureClient(p *print.Printer) (*logme.APIClient, error) { - var err error - var apiClient *logme.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.LogMeCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = logme.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*logme.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.LogMeCustomEndpointKey), true, genericclient.CreateApiClient[*logme.APIClient](logme.NewAPIClient)) } diff --git a/internal/pkg/services/logs/client/client.go b/internal/pkg/services/logs/client/client.go new file mode 100644 index 000000000..a5297b31a --- /dev/null +++ b/internal/pkg/services/logs/client/client.go @@ -0,0 +1,15 @@ +package client + +import ( + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*logs.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.LogsCustomEndpointKey), false, genericclient.CreateApiClient[*logs.APIClient](logs.NewAPIClient)) +} diff --git a/internal/pkg/services/logs/utils/utils.go b/internal/pkg/services/logs/utils/utils.go new file mode 100644 index 000000000..d67272254 --- /dev/null +++ b/internal/pkg/services/logs/utils/utils.go @@ -0,0 +1,43 @@ +package utils + +import ( + "context" + "errors" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" +) + +var ( + ErrResponseNil = errors.New("response is nil") + ErrNameNil = errors.New("display name is nil") +) + +type LogsClient interface { + GetLogsInstanceExecute(ctx context.Context, projectId, regionId, instanceId string) (*logs.LogsInstance, error) + GetAccessTokenExecute(ctx context.Context, projectId string, regionId string, instanceId string, tId string) (*logs.AccessToken, error) +} + +func GetInstanceName(ctx context.Context, apiClient LogsClient, projectId, regionId, instanceId string) (string, error) { + resp, err := apiClient.GetLogsInstanceExecute(ctx, projectId, regionId, instanceId) + if err != nil { + return "", fmt.Errorf("get Logs instance: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.DisplayName == nil { + return "", ErrNameNil + } + return *resp.DisplayName, nil +} + +func GetAccessTokenName(ctx context.Context, apiClient LogsClient, projectId, regionId, instanceId, accessTokenId string) (string, error) { + resp, err := apiClient.GetAccessTokenExecute(ctx, projectId, regionId, instanceId, accessTokenId) + if err != nil { + return "", fmt.Errorf("get Logs access token: %w", err) + } else if resp == nil { + return "", ErrResponseNil + } else if resp.DisplayName == nil { + return "", ErrNameNil + } + return *resp.DisplayName, nil +} diff --git a/internal/pkg/services/logs/utils/utils_test.go b/internal/pkg/services/logs/utils/utils_test.go new file mode 100644 index 000000000..0f780c1d6 --- /dev/null +++ b/internal/pkg/services/logs/utils/utils_test.go @@ -0,0 +1,169 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-sdk-go/services/logs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/uuid" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testAccessTokenId = uuid.NewString() +) + +const ( + testInstanceName = "instance" + testRegion = "eu01" +) + +type logsClientMocked struct { + getInstanceFails bool + getInstanceResp *logs.LogsInstance + getAccessTokenFails bool + getAccessTokenResp *logs.AccessToken +} + +func (m *logsClientMocked) GetLogsInstanceExecute(_ context.Context, _, _, _ string) (*logs.LogsInstance, error) { + if m.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.getInstanceResp, nil +} + +func (m *logsClientMocked) GetAccessTokenExecute(_ context.Context, _, _, _, _ string) (*logs.AccessToken, error) { + if m.getAccessTokenFails { + return nil, fmt.Errorf("could not get access token") + } + return m.getAccessTokenResp, nil +} + +func TestGetInstanceName(t *testing.T) { + tests := []struct { + description string + getInstanceFails bool + getInstanceResp *logs.LogsInstance + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &logs.LogsInstance{ + DisplayName: utils.Ptr(testInstanceName), + }, + isValid: true, + expectedOutput: testInstanceName, + }, + { + description: "get instance fails", + getInstanceFails: true, + isValid: false, + }, + { + description: "response is nil", + getInstanceFails: false, + getInstanceResp: nil, + isValid: false, + }, + { + description: "name in response is nil", + getInstanceFails: false, + getInstanceResp: &logs.LogsInstance{ + DisplayName: nil, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &logsClientMocked{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + } + + output, err := GetInstanceName(context.Background(), client, testProjectId, testRegion, 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 TestGetAccessTokenName(t *testing.T) { + tests := []struct { + description string + getAccessTokenFails bool + getAccessTokenResp *logs.AccessToken + isValid bool + expectedOutput string + }{ + { + description: "base", + getAccessTokenResp: &logs.AccessToken{ + DisplayName: utils.Ptr(testInstanceName), + }, + isValid: true, + expectedOutput: testInstanceName, + }, + { + description: "get instance fails", + getAccessTokenFails: true, + isValid: false, + }, + { + description: "response is nil", + getAccessTokenFails: false, + getAccessTokenResp: nil, + isValid: false, + }, + { + description: "name in response is nil", + getAccessTokenFails: false, + getAccessTokenResp: &logs.AccessToken{ + DisplayName: nil, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &logsClientMocked{ + getAccessTokenFails: tt.getAccessTokenFails, + getAccessTokenResp: tt.getAccessTokenResp, + } + + output, err := GetAccessTokenName(context.Background(), client, testProjectId, testRegion, testInstanceId, testAccessTokenId) + + 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/mariadb/client/client.go b/internal/pkg/services/mariadb/client/client.go index d21e32f03..9952a324c 100644 --- a/internal/pkg/services/mariadb/client/client.go +++ b/internal/pkg/services/mariadb/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/mariadb" ) -func ConfigureClient(p *print.Printer) (*mariadb.APIClient, error) { - var err error - var apiClient *mariadb.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.MariaDBCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = mariadb.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*mariadb.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.MariaDBCustomEndpointKey), true, genericclient.CreateApiClient[*mariadb.APIClient](mariadb.NewAPIClient)) } diff --git a/internal/pkg/services/mongodbflex/client/client.go b/internal/pkg/services/mongodbflex/client/client.go index 65f9ec696..7bb81905e 100644 --- a/internal/pkg/services/mongodbflex/client/client.go +++ b/internal/pkg/services/mongodbflex/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" - "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" - "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" -) - -func ConfigureClient(p *print.Printer) (*mongodbflex.APIClient, error) { - var err error - var apiClient *mongodbflex.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - customEndpoint := viper.GetString(config.MongoDBFlexCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = mongodbflex.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*mongodbflex.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.MongoDBFlexCustomEndpointKey), false, genericclient.CreateApiClient[*mongodbflex.APIClient](mongodbflex.NewAPIClient)) } diff --git a/internal/pkg/services/mongodbflex/utils/utils.go b/internal/pkg/services/mongodbflex/utils/utils.go index ad2e07f9e..352cdc948 100644 --- a/internal/pkg/services/mongodbflex/utils/utils.go +++ b/internal/pkg/services/mongodbflex/utils/utils.go @@ -7,9 +7,10 @@ import ( "slices" "strings" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "golang.org/x/mod/semver" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" ) @@ -21,10 +22,10 @@ var instanceTypeToReplicas = map[string]int64{ } type MongoDBFlexClient interface { - ListVersionsExecute(ctx context.Context, projectId string) (*mongodbflex.ListVersionsResponse, error) - GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*mongodbflex.GetInstanceResponse, error) - GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*mongodbflex.GetUserResponse, error) - ListRestoreJobsExecute(ctx context.Context, projectId string, instanceId string) (*mongodbflex.ListRestoreJobsResponse, error) + ListVersionsExecute(ctx context.Context, projectId, region string) (*mongodbflex.ListVersionsResponse, error) + GetInstanceExecute(ctx context.Context, projectId, instanceId, region string) (*mongodbflex.InstanceResponse, error) + GetUserExecute(ctx context.Context, projectId, instanceId, userId, region string) (*mongodbflex.GetUserResponse, error) + ListRestoreJobsExecute(ctx context.Context, projectId string, instanceId, region string) (*mongodbflex.ListRestoreJobsResponse, error) } func AvailableInstanceTypes() []string { @@ -57,7 +58,7 @@ func GetInstanceType(numReplicas int64) (string, error) { return "", fmt.Errorf("invalid number of replicas: %v", numReplicas) } -func ValidateFlavorId(flavorId string, flavors *[]mongodbflex.HandlersInfraFlavor) error { +func ValidateFlavorId(flavorId string, flavors *[]mongodbflex.InstanceFlavor) error { if flavors == nil { return fmt.Errorf("nil flavors") } @@ -101,7 +102,7 @@ func ValidateStorage(storageClass *string, storageSize *int64, storages *mongodb } } -func LoadFlavorId(cpu, ram int64, flavors *[]mongodbflex.HandlersInfraFlavor) (*string, error) { +func LoadFlavorId(cpu, ram int64, flavors *[]mongodbflex.InstanceFlavor) (*string, error) { if flavors == nil { return nil, fmt.Errorf("nil flavors") } @@ -122,8 +123,8 @@ func LoadFlavorId(cpu, ram int64, flavors *[]mongodbflex.HandlersInfraFlavor) (* } } -func GetLatestMongoDBVersion(ctx context.Context, apiClient MongoDBFlexClient, projectId string) (string, error) { - resp, err := apiClient.ListVersionsExecute(ctx, projectId) +func GetLatestMongoDBVersion(ctx context.Context, apiClient MongoDBFlexClient, projectId, region string) (string, error) { + resp, err := apiClient.ListVersionsExecute(ctx, projectId, region) if err != nil { return "", fmt.Errorf("get MongoDB versions: %w", err) } @@ -144,16 +145,16 @@ func GetLatestMongoDBVersion(ctx context.Context, apiClient MongoDBFlexClient, p return latestVersion, nil } -func GetInstanceName(ctx context.Context, apiClient MongoDBFlexClient, projectId, instanceId string) (string, error) { - resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) +func GetInstanceName(ctx context.Context, apiClient MongoDBFlexClient, projectId, instanceId, region string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId, region) if err != nil { return "", fmt.Errorf("get MongoDB Flex 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) +func GetUserName(ctx context.Context, apiClient MongoDBFlexClient, projectId, instanceId, userId, region string) (string, error) { + resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId, region) if err != nil { return "", fmt.Errorf("get MongoDB Flex user: %w", err) } diff --git a/internal/pkg/services/mongodbflex/utils/utils_test.go b/internal/pkg/services/mongodbflex/utils/utils_test.go index 1024c5710..157bc2803 100644 --- a/internal/pkg/services/mongodbflex/utils/utils_test.go +++ b/internal/pkg/services/mongodbflex/utils/utils_test.go @@ -20,6 +20,7 @@ var ( ) const ( + testRegion = "eu02" testInstanceName = "instance" testUserName = "user" ) @@ -28,35 +29,35 @@ type mongoDBFlexClientMocked struct { listVersionsFails bool listVersionsResp *mongodbflex.ListVersionsResponse getInstanceFails bool - getInstanceResp *mongodbflex.GetInstanceResponse + getInstanceResp *mongodbflex.InstanceResponse getUserFails bool getUserResp *mongodbflex.GetUserResponse listRestoreJobsFails bool listRestoreJobsResp *mongodbflex.ListRestoreJobsResponse } -func (m *mongoDBFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*mongodbflex.ListVersionsResponse, error) { +func (m *mongoDBFlexClientMocked) ListVersionsExecute(_ context.Context, _, _ string) (*mongodbflex.ListVersionsResponse, error) { if m.listVersionsFails { return nil, fmt.Errorf("could not list versions") } return m.listVersionsResp, nil } -func (m *mongoDBFlexClientMocked) ListRestoreJobsExecute(_ context.Context, _, _ string) (*mongodbflex.ListRestoreJobsResponse, error) { +func (m *mongoDBFlexClientMocked) ListRestoreJobsExecute(_ context.Context, _, _, _ string) (*mongodbflex.ListRestoreJobsResponse, error) { if m.listRestoreJobsFails { return nil, fmt.Errorf("could not list versions") } return m.listRestoreJobsResp, nil } -func (m *mongoDBFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*mongodbflex.GetInstanceResponse, error) { +func (m *mongoDBFlexClientMocked) GetInstanceExecute(_ context.Context, _, _, _ string) (*mongodbflex.InstanceResponse, 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) { +func (m *mongoDBFlexClientMocked) GetUserExecute(_ context.Context, _, _, _, _ string) (*mongodbflex.GetUserResponse, error) { if m.getUserFails { return nil, fmt.Errorf("could not get user") } @@ -175,13 +176,13 @@ func TestValidateFlavorId(t *testing.T) { tests := []struct { description string flavorId string - flavors *[]mongodbflex.HandlersInfraFlavor + flavors *[]mongodbflex.InstanceFlavor isValid bool }{ { description: "base", flavorId: "foo", - flavors: &[]mongodbflex.HandlersInfraFlavor{ + flavors: &[]mongodbflex.InstanceFlavor{ {Id: utils.Ptr("bar-1")}, {Id: utils.Ptr("bar-2")}, {Id: utils.Ptr("foo")}, @@ -197,13 +198,13 @@ func TestValidateFlavorId(t *testing.T) { { description: "no flavors", flavorId: "foo", - flavors: &[]mongodbflex.HandlersInfraFlavor{}, + flavors: &[]mongodbflex.InstanceFlavor{}, isValid: false, }, { description: "nil flavor id", flavorId: "foo", - flavors: &[]mongodbflex.HandlersInfraFlavor{ + flavors: &[]mongodbflex.InstanceFlavor{ {Id: utils.Ptr("bar-1")}, {Id: nil}, {Id: utils.Ptr("foo")}, @@ -213,7 +214,7 @@ func TestValidateFlavorId(t *testing.T) { { description: "invalid flavor", flavorId: "foo", - flavors: &[]mongodbflex.HandlersInfraFlavor{ + flavors: &[]mongodbflex.InstanceFlavor{ {Id: utils.Ptr("bar-1")}, {Id: utils.Ptr("bar-2")}, {Id: utils.Ptr("bar-3")}, @@ -240,7 +241,7 @@ func TestLoadFlavorId(t *testing.T) { description string cpu int64 ram int64 - flavors *[]mongodbflex.HandlersInfraFlavor + flavors *[]mongodbflex.InstanceFlavor isValid bool expectedOutput *string }{ @@ -248,7 +249,7 @@ func TestLoadFlavorId(t *testing.T) { description: "base", cpu: 2, ram: 4, - flavors: &[]mongodbflex.HandlersInfraFlavor{ + flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr("bar-1"), Cpu: utils.Ptr(int64(2)), @@ -279,14 +280,14 @@ func TestLoadFlavorId(t *testing.T) { description: "no flavors", cpu: 2, ram: 4, - flavors: &[]mongodbflex.HandlersInfraFlavor{}, + flavors: &[]mongodbflex.InstanceFlavor{}, isValid: false, }, { description: "flavors with details missing", cpu: 2, ram: 4, - flavors: &[]mongodbflex.HandlersInfraFlavor{ + flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr("bar-1"), Cpu: nil, @@ -310,7 +311,7 @@ func TestLoadFlavorId(t *testing.T) { description: "match with nil id", cpu: 2, ram: 4, - flavors: &[]mongodbflex.HandlersInfraFlavor{ + flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr("bar-1"), Cpu: utils.Ptr(int64(2)), @@ -333,7 +334,7 @@ func TestLoadFlavorId(t *testing.T) { description: "invalid settings", cpu: 2, ram: 4, - flavors: &[]mongodbflex.HandlersInfraFlavor{ + flavors: &[]mongodbflex.InstanceFlavor{ { Id: utils.Ptr("bar-1"), Cpu: utils.Ptr(int64(2)), @@ -411,7 +412,7 @@ func TestGetLatestMongoDBFlexVersion(t *testing.T) { listVersionsResp: tt.listVersionsResp, } - output, err := GetLatestMongoDBVersion(context.Background(), client, testProjectId) + output, err := GetLatestMongoDBVersion(context.Background(), client, testProjectId, testRegion) if tt.isValid && err != nil { t.Errorf("failed on valid input") @@ -433,13 +434,13 @@ func TestGetInstanceName(t *testing.T) { tests := []struct { description string getInstanceFails bool - getInstanceResp *mongodbflex.GetInstanceResponse + getInstanceResp *mongodbflex.InstanceResponse isValid bool expectedOutput string }{ { description: "base", - getInstanceResp: &mongodbflex.GetInstanceResponse{ + getInstanceResp: &mongodbflex.InstanceResponse{ Item: &mongodbflex.Instance{ Name: utils.Ptr(testInstanceName), }, @@ -461,7 +462,7 @@ func TestGetInstanceName(t *testing.T) { getInstanceResp: tt.getInstanceResp, } - output, err := GetInstanceName(context.Background(), client, testProjectId, testInstanceId) + output, err := GetInstanceName(context.Background(), client, testProjectId, testInstanceId, testRegion) if tt.isValid && err != nil { t.Errorf("failed on valid input") @@ -511,7 +512,7 @@ func TestGetUserName(t *testing.T) { getUserResp: tt.getUserResp, } - output, err := GetUserName(context.Background(), client, testProjectId, testInstanceId, testUserId) + output, err := GetUserName(context.Background(), client, testProjectId, testInstanceId, testUserId, testRegion) if tt.isValid && err != nil { t.Errorf("failed on valid input") diff --git a/internal/pkg/services/network-area/routing-table/utils/utils.go b/internal/pkg/services/network-area/routing-table/utils/utils.go new file mode 100644 index 000000000..9783dd918 --- /dev/null +++ b/internal/pkg/services/network-area/routing-table/utils/utils.go @@ -0,0 +1,66 @@ +package utils + +import ( + "fmt" + "time" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +type RouteDetails struct { + DestType string + DestValue string + HopType string + HopValue string + CreatedAt string + UpdatedAt string + Labels string +} + +func ExtractRouteDetails(route iaas.Route) RouteDetails { + var routeDetails RouteDetails + + if route.Destination != nil { + if route.Destination.DestinationCIDRv4 != nil { + routeDetails.DestType = utils.PtrString(route.Destination.DestinationCIDRv4.Type) + routeDetails.DestValue = utils.PtrString(route.Destination.DestinationCIDRv4.Value) + } else if route.Destination.DestinationCIDRv6 != nil { + routeDetails.DestType = utils.PtrString(route.Destination.DestinationCIDRv6.Type) + routeDetails.DestValue = utils.PtrString(route.Destination.DestinationCIDRv6.Value) + } + } + + if route.Nexthop != nil { + if route.Nexthop.NexthopIPv4 != nil { + routeDetails.HopType = utils.PtrString(route.Nexthop.NexthopIPv4.Type) + routeDetails.HopValue = utils.PtrString(route.Nexthop.NexthopIPv4.Value) + } else if route.Nexthop.NexthopIPv6 != nil { + routeDetails.HopType = utils.PtrString(route.Nexthop.NexthopIPv6.Type) + routeDetails.HopValue = utils.PtrString(route.Nexthop.NexthopIPv6.Value) + } else if route.Nexthop.NexthopInternet != nil { + routeDetails.HopType = utils.PtrString(route.Nexthop.NexthopInternet.Type) + } else if route.Nexthop.NexthopBlackhole != nil { + routeDetails.HopType = utils.PtrString(route.Nexthop.NexthopBlackhole.Type) + } + } + + if route.Labels != nil && len(*route.Labels) > 0 { + stringMap := make(map[string]string) + for key, value := range *route.Labels { + stringMap[key] = fmt.Sprintf("%v", value) + } + routeDetails.Labels = utils.JoinStringMap(stringMap, ": ", "\n") + } + + if route.CreatedAt != nil { + routeDetails.CreatedAt = route.CreatedAt.Format(time.RFC3339) + } + + if route.UpdatedAt != nil { + routeDetails.UpdatedAt = route.UpdatedAt.Format(time.RFC3339) + } + + return routeDetails +} diff --git a/internal/pkg/services/network-area/routing-table/utils/utils_test.go b/internal/pkg/services/network-area/routing-table/utils/utils_test.go new file mode 100644 index 000000000..d5deee061 --- /dev/null +++ b/internal/pkg/services/network-area/routing-table/utils/utils_test.go @@ -0,0 +1,269 @@ +package utils + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ipv4 = "ipv4" +const ipv6 = "ipv6" +const cidrv4 = "cidrv4" +const cidrv6 = "cidrv6" + +func TestExtractRouteDetails(t *testing.T) { + created := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + updated := time.Date(2024, 1, 2, 4, 5, 6, 0, time.UTC) + + tests := []struct { + description string + input *iaas.Route + want RouteDetails + }{ + { + description: "completely empty route (zero value)", + input: &iaas.Route{}, + want: RouteDetails{}, + }, + { + description: "destination only, no nexthop, no labels", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(cidrv4), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + }, + }, + { + description: "nexthop only, no destination, empty labels map", + input: &iaas.Route{ + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(ipv4), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: &map[string]interface{}{}, // empty but non-nil + }, + want: RouteDetails{ + HopType: ipv4, + HopValue: "10.0.0.1", + }, + }, + { + description: "destination present, nexthop struct nil, labels nil", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr(cidrv6), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + Nexthop: nil, + Labels: nil, + }, + want: RouteDetails{ + DestType: cidrv6, + DestValue: "2001:db8::/32", + }, + }, + { + description: "CIDRv4 destination, IPv4 nexthop, with labels", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(cidrv4), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(ipv4), + Value: utils.Ptr("10.0.0.1"), + }, + }, + Labels: &map[string]interface{}{ + "key": "value", + }, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + HopType: ipv4, + HopValue: "10.0.0.1", + Labels: "key: value", + }, + }, + { + description: "CIDRv6 destination, IPv6 nexthop, with no labels", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr(cidrv6), + Value: utils.Ptr("2001:db8::/32"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv6: &iaas.NexthopIPv6{ + Type: utils.Ptr(ipv6), + Value: utils.Ptr("2001:db8::1"), + }, + }, + Labels: nil, + }, + want: RouteDetails{ + DestType: cidrv6, + DestValue: "2001:db8::/32", + HopType: ipv6, + HopValue: "2001:db8::1", + }, + }, + { + description: "Internet nexthop without value", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(cidrv4), + Value: utils.Ptr("0.0.0.0/0"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopInternet: &iaas.NexthopInternet{ + Type: utils.Ptr("Internet"), + }, + }, + Labels: nil, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "0.0.0.0/0", + HopType: "Internet", + // HopValue empty + }, + }, + { + description: "Blackhole nexthop without value and nil labels map", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv6: &iaas.DestinationCIDRv6{ + Type: utils.Ptr(cidrv6), + Value: utils.Ptr("::/0"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopBlackhole: &iaas.NexthopBlackhole{ + Type: utils.Ptr("Blackhole"), + }, + }, + Labels: nil, + }, + want: RouteDetails{ + DestType: cidrv6, + DestValue: "::/0", + HopType: "Blackhole", + }, + }, + { + description: "route with created and updated timestamps", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(cidrv4), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(ipv4), + Value: utils.Ptr("10.0.0.1"), + }, + }, + CreatedAt: &created, + UpdatedAt: &updated, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + HopType: ipv4, + HopValue: "10.0.0.1", + CreatedAt: created.Format(time.RFC3339), + UpdatedAt: updated.Format(time.RFC3339), + Labels: "", + }, + }, + { + description: "route with created timestamp only", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(cidrv4), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(ipv4), + Value: utils.Ptr("10.0.0.1"), + }, + }, + CreatedAt: &created, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + HopType: ipv4, + HopValue: "10.0.0.1", + CreatedAt: created.Format(time.RFC3339), + UpdatedAt: "", + Labels: "", + }, + }, + { + description: "route with updated timestamp only", + input: &iaas.Route{ + Destination: &iaas.RouteDestination{ + DestinationCIDRv4: &iaas.DestinationCIDRv4{ + Type: utils.Ptr(cidrv4), + Value: utils.Ptr("10.0.0.0/24"), + }, + }, + Nexthop: &iaas.RouteNexthop{ + NexthopIPv4: &iaas.NexthopIPv4{ + Type: utils.Ptr(ipv4), + Value: utils.Ptr("10.0.0.1"), + }, + }, + UpdatedAt: &updated, + }, + want: RouteDetails{ + DestType: cidrv4, + DestValue: "10.0.0.0/24", + HopType: ipv4, + HopValue: "10.0.0.1", + CreatedAt: "", + UpdatedAt: updated.Format(time.RFC3339), + Labels: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got := ExtractRouteDetails(*tt.input) + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("ExtractRouteDetails() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/pkg/services/object-storage/client/client.go b/internal/pkg/services/object-storage/client/client.go index 13ca5c470..81e5dbe95 100644 --- a/internal/pkg/services/object-storage/client/client.go +++ b/internal/pkg/services/object-storage/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -func ConfigureClient(p *print.Printer) (*objectstorage.APIClient, error) { - var err error - var apiClient *objectstorage.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.ObjectStorageCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = objectstorage.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*objectstorage.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ObjectStorageCustomEndpointKey), false, genericclient.CreateApiClient[*objectstorage.APIClient](objectstorage.NewAPIClient)) } diff --git a/internal/pkg/services/object-storage/utils/utils.go b/internal/pkg/services/object-storage/utils/utils.go index bd23d0854..a122cb1ef 100644 --- a/internal/pkg/services/object-storage/utils/utils.go +++ b/internal/pkg/services/object-storage/utils/utils.go @@ -6,17 +6,11 @@ import ( "net/http" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" ) -type ObjectStorageClient interface { - GetServiceStatusExecute(ctx context.Context, projectId, region string) (*objectstorage.ProjectStatus, error) - ListCredentialsGroupsExecute(ctx context.Context, projectId, region string) (*objectstorage.ListCredentialsGroupsResponse, error) - ListAccessKeys(ctx context.Context, projectId, region string) objectstorage.ApiListAccessKeysRequest -} - -func ProjectEnabled(ctx context.Context, apiClient ObjectStorageClient, projectId, region string) (bool, error) { - _, err := apiClient.GetServiceStatusExecute(ctx, projectId, region) +func ProjectEnabled(ctx context.Context, apiClient objectstorage.DefaultAPI, projectId, region string) (bool, error) { + _, err := apiClient.GetServiceStatus(ctx, projectId, region).Execute() if err != nil { oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped if !ok { @@ -30,8 +24,8 @@ func ProjectEnabled(ctx context.Context, apiClient ObjectStorageClient, projectI return true, nil } -func GetCredentialsGroupName(ctx context.Context, apiClient ObjectStorageClient, projectId, credentialsGroupId, region string) (string, error) { - resp, err := apiClient.ListCredentialsGroupsExecute(ctx, projectId, region) +func GetCredentialsGroupName(ctx context.Context, apiClient objectstorage.DefaultAPI, projectId, credentialsGroupId, region string) (string, error) { + resp, err := apiClient.ListCredentialsGroups(ctx, projectId, region).Execute() if err != nil { return "", fmt.Errorf("list Object Storage credentials groups: %w", err) } @@ -41,16 +35,16 @@ func GetCredentialsGroupName(ctx context.Context, apiClient ObjectStorageClient, return "", fmt.Errorf("nil Object Storage credentials group list: %w", err) } - for _, group := range *credentialsGroups { - if group.CredentialsGroupId != nil && *group.CredentialsGroupId == credentialsGroupId && group.DisplayName != nil && *group.DisplayName != "" { - return *group.DisplayName, nil + for _, group := range credentialsGroups { + if group.CredentialsGroupId == credentialsGroupId && group.DisplayName != "" { + return group.DisplayName, nil } } return "", fmt.Errorf("could not find Object Storage credentials group name") } -func GetCredentialsName(ctx context.Context, apiClient ObjectStorageClient, projectId, credentialsGroupId, keyId, region string) (string, error) { +func GetCredentialsName(ctx context.Context, apiClient objectstorage.DefaultAPI, projectId, credentialsGroupId, keyId, region string) (string, error) { req := apiClient.ListAccessKeys(ctx, projectId, region) req = req.CredentialsGroup(credentialsGroupId) resp, err := req.Execute() @@ -64,9 +58,9 @@ func GetCredentialsName(ctx context.Context, apiClient ObjectStorageClient, proj return "", fmt.Errorf("nil Object Storage credentials list") } - for _, credential := range *credentials { - if credential.KeyId != nil && *credential.KeyId == keyId && credential.DisplayName != nil && *credential.DisplayName != "" { - return *credential.DisplayName, nil + for _, credential := range credentials { + if credential.KeyId == keyId && credential.DisplayName != "" { + return credential.DisplayName, nil } } diff --git a/internal/pkg/services/object-storage/utils/utils_test.go b/internal/pkg/services/object-storage/utils/utils_test.go index 65b176172..1a93b9968 100644 --- a/internal/pkg/services/object-storage/utils/utils_test.go +++ b/internal/pkg/services/object-storage/utils/utils_test.go @@ -2,59 +2,69 @@ package utils import ( "context" - "encoding/json" "fmt" "net/http" - "net/http/httptest" "testing" "github.com/google/uuid" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" - "github.com/stackitcloud/stackit-sdk-go/services/objectstorage" + objectstorage "github.com/stackitcloud/stackit-sdk-go/services/objectstorage/v2api" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) var ( testProjectId = uuid.NewString() testCredentialsGroupId = uuid.NewString() - testCredentialsId = "credentialsID" //nolint:gosec // linter false positive - testRegion = "eu01" ) const ( testCredentialsGroupName = "testGroup" testCredentialsName = "testCredential" + testCredentialsId = "credentialsID" //nolint:gosec // linter false positive + testRegion = "eu01" ) -type objectStorageClientMocked struct { - serviceDisabled bool - getServiceStatusFails bool +type mockSettings struct { + serviceDisabled bool + getServiceStatusFails bool + listCredentialsGroupsFails bool listCredentialsGroupsResp *objectstorage.ListCredentialsGroupsResponse - listAccessKeysReq objectstorage.ApiListAccessKeysRequest -} -func (m *objectStorageClientMocked) GetServiceStatusExecute(_ context.Context, _, _ string) (*objectstorage.ProjectStatus, error) { - if m.getServiceStatusFails { - return nil, fmt.Errorf("could not get service status") - } - if m.serviceDisabled { - return nil, &oapierror.GenericOpenAPIError{StatusCode: 404} - } - return &objectstorage.ProjectStatus{}, nil + listAccessKeysFails bool + listAccessKeysResp *objectstorage.ListAccessKeysResponse } -func (m *objectStorageClientMocked) ListCredentialsGroupsExecute(_ context.Context, _, _ string) (*objectstorage.ListCredentialsGroupsResponse, error) { - if m.listCredentialsGroupsFails { - return nil, fmt.Errorf("could not list credentials groups") - } - return m.listCredentialsGroupsResp, nil -} +func newAPIMock(settings *mockSettings) objectstorage.DefaultAPI { + return &objectstorage.DefaultAPIServiceMock{ + GetServiceStatusExecuteMock: utils.Ptr(func(_ objectstorage.ApiGetServiceStatusRequest) (*objectstorage.ProjectStatus, error) { + if settings.getServiceStatusFails { + return nil, fmt.Errorf("could not get service status") + } -func (m *objectStorageClientMocked) ListAccessKeys(_ context.Context, _, _ string) objectstorage.ApiListAccessKeysRequest { - return m.listAccessKeysReq + if settings.serviceDisabled { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusNotFound} + } + + return &objectstorage.ProjectStatus{}, nil + }), + ListCredentialsGroupsExecuteMock: utils.Ptr(func(_ objectstorage.ApiListCredentialsGroupsRequest) (*objectstorage.ListCredentialsGroupsResponse, error) { + if settings.listCredentialsGroupsFails { + return nil, fmt.Errorf("could not list credentials groups") + } + + return settings.listCredentialsGroupsResp, nil + }), + ListAccessKeysExecuteMock: utils.Ptr(func(_ objectstorage.ApiListAccessKeysRequest) (*objectstorage.ListAccessKeysResponse, error) { + if settings.listAccessKeysFails { + return nil, &oapierror.GenericOpenAPIError{StatusCode: http.StatusBadGateway} + } + + return settings.listAccessKeysResp, nil + }), + } } func TestProjectEnabled(t *testing.T) { @@ -85,10 +95,10 @@ func TestProjectEnabled(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - client := &objectStorageClientMocked{ + client := newAPIMock(&mockSettings{ serviceDisabled: tt.serviceDisabled, getServiceStatusFails: tt.getProjectFails, - } + }) output, err := ProjectEnabled(context.Background(), client, testProjectId, testRegion) @@ -120,10 +130,10 @@ func TestGetCredentialsGroupName(t *testing.T) { { description: "base", listCredentialsGroupsResp: &objectstorage.ListCredentialsGroupsResponse{ - CredentialsGroups: &[]objectstorage.CredentialsGroup{ + CredentialsGroups: []objectstorage.CredentialsGroup{ { - CredentialsGroupId: utils.Ptr(testCredentialsGroupId), - DisplayName: utils.Ptr(testCredentialsGroupName), + CredentialsGroupId: testCredentialsGroupId, + DisplayName: testCredentialsGroupName, }, }, }, @@ -138,14 +148,14 @@ func TestGetCredentialsGroupName(t *testing.T) { { description: "multiple credentials groups", listCredentialsGroupsResp: &objectstorage.ListCredentialsGroupsResponse{ - CredentialsGroups: &[]objectstorage.CredentialsGroup{ + CredentialsGroups: []objectstorage.CredentialsGroup{ { - CredentialsGroupId: utils.Ptr("test-UUID"), - DisplayName: utils.Ptr("test-name"), + CredentialsGroupId: "test-UUID", + DisplayName: "test-name", }, { - CredentialsGroupId: utils.Ptr(testCredentialsGroupId), - DisplayName: utils.Ptr(testCredentialsGroupName), + CredentialsGroupId: testCredentialsGroupId, + DisplayName: testCredentialsGroupName, }, }, }, @@ -160,23 +170,11 @@ func TestGetCredentialsGroupName(t *testing.T) { isValid: false, }, { - description: "nil credentials group id", + description: "empty credentials group id", listCredentialsGroupsResp: &objectstorage.ListCredentialsGroupsResponse{ - CredentialsGroups: &[]objectstorage.CredentialsGroup{ + CredentialsGroups: []objectstorage.CredentialsGroup{ { - CredentialsGroupId: nil, - }, - }, - }, - isValid: false, - }, - { - description: "nil credentials group name", - listCredentialsGroupsResp: &objectstorage.ListCredentialsGroupsResponse{ - CredentialsGroups: &[]objectstorage.CredentialsGroup{ - { - CredentialsGroupId: utils.Ptr(testCredentialsGroupId), - DisplayName: nil, + CredentialsGroupId: "", }, }, }, @@ -185,10 +183,10 @@ func TestGetCredentialsGroupName(t *testing.T) { { description: "empty credentials group name", listCredentialsGroupsResp: &objectstorage.ListCredentialsGroupsResponse{ - CredentialsGroups: &[]objectstorage.CredentialsGroup{ + CredentialsGroups: []objectstorage.CredentialsGroup{ { - CredentialsGroupId: utils.Ptr(testCredentialsGroupId), - DisplayName: utils.Ptr(""), + CredentialsGroupId: testCredentialsGroupId, + DisplayName: "", }, }, }, @@ -198,10 +196,10 @@ func TestGetCredentialsGroupName(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - client := &objectStorageClientMocked{ + client := newAPIMock(&mockSettings{ listCredentialsGroupsFails: tt.listCredentialsGroupsFails, listCredentialsGroupsResp: tt.listCredentialsGroupsResp, - } + }) output, err := GetCredentialsGroupName(context.Background(), client, testProjectId, testCredentialsGroupId, testRegion) @@ -232,10 +230,10 @@ func TestGetCredentialsName(t *testing.T) { { description: "base", listAccessKeysResp: &objectstorage.ListAccessKeysResponse{ - AccessKeys: &[]objectstorage.AccessKey{ + AccessKeys: []objectstorage.AccessKey{ { - KeyId: utils.Ptr(testCredentialsId), - DisplayName: utils.Ptr(testCredentialsName), + KeyId: testCredentialsId, + DisplayName: testCredentialsName, }, }, }, @@ -250,14 +248,14 @@ func TestGetCredentialsName(t *testing.T) { { description: "multiple credentials", listAccessKeysResp: &objectstorage.ListAccessKeysResponse{ - AccessKeys: &[]objectstorage.AccessKey{ + AccessKeys: []objectstorage.AccessKey{ { - KeyId: utils.Ptr("test-UUID"), - DisplayName: utils.Ptr("test-name"), + KeyId: "test-UUID", + DisplayName: "test-name", }, { - KeyId: utils.Ptr(testCredentialsId), - DisplayName: utils.Ptr(testCredentialsName), + KeyId: testCredentialsId, + DisplayName: testCredentialsName, }, }, }, @@ -272,23 +270,11 @@ func TestGetCredentialsName(t *testing.T) { isValid: false, }, { - description: "nil credentials id", - listAccessKeysResp: &objectstorage.ListAccessKeysResponse{ - AccessKeys: &[]objectstorage.AccessKey{ - { - KeyId: nil, - }, - }, - }, - isValid: false, - }, - { - description: "nil credentials name", + description: "empty credentials id", listAccessKeysResp: &objectstorage.ListAccessKeysResponse{ - AccessKeys: &[]objectstorage.AccessKey{ + AccessKeys: []objectstorage.AccessKey{ { - KeyId: utils.Ptr(testCredentialsId), - DisplayName: nil, + KeyId: "", }, }, }, @@ -297,10 +283,10 @@ func TestGetCredentialsName(t *testing.T) { { description: "empty credentials name", listAccessKeysResp: &objectstorage.ListAccessKeysResponse{ - AccessKeys: &[]objectstorage.AccessKey{ + AccessKeys: []objectstorage.AccessKey{ { - KeyId: utils.Ptr(testCredentialsId), - DisplayName: utils.Ptr(""), + KeyId: testCredentialsId, + DisplayName: "", }, }, }, @@ -310,37 +296,10 @@ func TestGetCredentialsName(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - mockedRespBytes, err := json.Marshal(tt.listAccessKeysResp) - if err != nil { - t.Fatalf("Failed to marshal mocked response: %v", err) - } - - handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - if tt.getCredentialsNameFails { - w.WriteHeader(http.StatusBadGateway) - w.Header().Set("Content-Type", "application/json") - _, err := w.Write([]byte("{\"message\": \"Something bad happened\"")) - if err != nil { - t.Errorf("Failed to write bad response: %v", err) - } - return - } - - _, err := w.Write(mockedRespBytes) - if err != nil { - t.Errorf("Failed to write response: %v", err) - } + client := newAPIMock(&mockSettings{ + listAccessKeysFails: tt.getCredentialsNameFails, + listAccessKeysResp: tt.listAccessKeysResp, }) - mockedServer := httptest.NewServer(handler) - defer mockedServer.Close() - client, err := objectstorage.NewAPIClient( - config.WithEndpoint(mockedServer.URL), - config.WithoutAuthentication(), - ) - if err != nil { - t.Fatalf("Failed to initialize client: %v", err) - } output, err := GetCredentialsName(context.Background(), client, testProjectId, testCredentialsGroupId, testCredentialsId, testRegion) diff --git a/internal/pkg/services/observability/client/client.go b/internal/pkg/services/observability/client/client.go index e629915d3..eae8204d7 100644 --- a/internal/pkg/services/observability/client/client.go +++ b/internal/pkg/services/observability/client/client.go @@ -1,46 +1,15 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-sdk-go/services/observability" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - "github.com/stackitcloud/stackit-sdk-go/services/observability" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" ) -func ConfigureClient(p *print.Printer) (*observability.APIClient, error) { - var err error - var apiClient *observability.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.ObservabilityCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = observability.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*observability.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ObservabilityCustomEndpointKey), true, genericclient.CreateApiClient[*observability.APIClient](observability.NewAPIClient)) } diff --git a/internal/pkg/services/observability/utils/utils.go b/internal/pkg/services/observability/utils/utils.go index fef48457e..7a15180c5 100644 --- a/internal/pkg/services/observability/utils/utils.go +++ b/internal/pkg/services/observability/utils/utils.go @@ -5,9 +5,10 @@ import ( "fmt" "strings" + "github.com/stackitcloud/stackit-sdk-go/services/observability" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/stackitcloud/stackit-sdk-go/services/observability" ) const ( @@ -21,7 +22,7 @@ type ObservabilityClient interface { } var ( - defaultStaticConfigs = []observability.CreateScrapeConfigPayloadStaticConfigsInner{ + defaultStaticConfigs = []observability.PartialUpdateScrapeConfigsRequestInnerStaticConfigsInner{ { Targets: utils.Ptr([]string{ "url-target", @@ -31,7 +32,7 @@ var ( DefaultCreateScrapeConfigPayload = observability.CreateScrapeConfigPayload{ JobName: utils.Ptr("default-name"), MetricsPath: utils.Ptr("/metrics"), - Scheme: utils.Ptr("https"), + Scheme: observability.CREATESCRAPECONFIGPAYLOADSCHEME_HTTPS.Ptr(), ScrapeInterval: utils.Ptr("5m"), ScrapeTimeout: utils.Ptr("2m"), StaticConfigs: utils.Ptr(defaultStaticConfigs), @@ -106,7 +107,7 @@ func MapToUpdateScrapeConfigPayload(resp *observability.GetScrapeConfigResponse) MetricsRelabelConfigs: metricsRelabelConfigs, Params: params, SampleLimit: utils.ConvertInt64PToFloat64P(data.SampleLimit), - Scheme: data.Scheme, + Scheme: observability.UpdateScrapeConfigPayloadGetSchemeAttributeType(data.Scheme), ScrapeInterval: data.ScrapeInterval, ScrapeTimeout: data.ScrapeTimeout, StaticConfigs: staticConfigs, @@ -120,14 +121,14 @@ func MapToUpdateScrapeConfigPayload(resp *observability.GetScrapeConfigResponse) return &payload, nil } -func mapMetricsRelabelConfig(metricsRelabelConfigs *[]observability.MetricsRelabelConfig) *[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner { +func mapMetricsRelabelConfig(metricsRelabelConfigs *[]observability.MetricsRelabelConfig) *[]observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInner { if metricsRelabelConfigs == nil { return nil } - var mappedConfigs []observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner + var mappedConfigs []observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInner for _, config := range *metricsRelabelConfigs { - mappedConfig := observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{ - Action: config.Action, + mappedConfig := observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInner{ + Action: observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInnerGetActionAttributeType(config.Action), Modulus: utils.ConvertInt64PToFloat64P(config.Modulus), Regex: config.Regex, Replacement: config.Replacement, @@ -160,23 +161,23 @@ func mapStaticConfig(staticConfigs *[]observability.StaticConfigs) *[]observabil return &mappedConfigs } -func mapBasicAuth(basicAuth *observability.BasicAuth) *observability.CreateScrapeConfigPayloadBasicAuth { +func mapBasicAuth(basicAuth *observability.BasicAuth) *observability.PartialUpdateScrapeConfigsRequestInnerBasicAuth { if basicAuth == nil { return nil } - return &observability.CreateScrapeConfigPayloadBasicAuth{ + return &observability.PartialUpdateScrapeConfigsRequestInnerBasicAuth{ Password: basicAuth.Password, Username: basicAuth.Username, } } -func mapTlsConfig(tlsConfig *observability.TLSConfig) *observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig { +func mapTlsConfig(tlsConfig *observability.TLSConfig) *observability.PartialUpdateScrapeConfigsRequestInnerHttpSdConfigsInnerOauth2TlsConfig { if tlsConfig == nil { return nil } - return &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{ + return &observability.PartialUpdateScrapeConfigsRequestInnerHttpSdConfigsInnerOauth2TlsConfig{ InsecureSkipVerify: tlsConfig.InsecureSkipVerify, } } diff --git a/internal/pkg/services/observability/utils/utils_test.go b/internal/pkg/services/observability/utils/utils_test.go index 36484e547..18919db62 100644 --- a/internal/pkg/services/observability/utils/utils_test.go +++ b/internal/pkg/services/observability/utils/utils_test.go @@ -48,7 +48,7 @@ func fixtureGetScrapeConfigResponse(mods ...func(*observability.GetScrapeConfigR MetricsPath: utils.Ptr("/metrics"), MetricsRelabelConfigs: &[]observability.MetricsRelabelConfig{ { - Action: utils.Ptr("replace"), + Action: observability.METRICSRELABELCONFIGACTION_REPLACE.Ptr(), Modulus: &number, Regex: utils.Ptr("regex"), Replacement: utils.Ptr("replacement"), @@ -62,7 +62,7 @@ func fixtureGetScrapeConfigResponse(mods ...func(*observability.GetScrapeConfigR "key2": {}, }, SampleLimit: &number, - Scheme: utils.Ptr("scheme"), + Scheme: observability.JOBSCHEME_HTTP.Ptr(), ScrapeInterval: utils.Ptr("interval"), ScrapeTimeout: utils.Ptr("timeout"), StaticConfigs: &[]observability.StaticConfigs{ @@ -89,7 +89,7 @@ func fixtureGetScrapeConfigResponse(mods ...func(*observability.GetScrapeConfigR func fixtureUpdateScrapeConfigPayload(mods ...func(*observability.UpdateScrapeConfigPayload)) *observability.UpdateScrapeConfigPayload { payload := &observability.UpdateScrapeConfigPayload{ - BasicAuth: &observability.CreateScrapeConfigPayloadBasicAuth{ + BasicAuth: &observability.PartialUpdateScrapeConfigsRequestInnerBasicAuth{ Username: utils.Ptr("username"), Password: utils.Ptr("password"), }, @@ -97,9 +97,9 @@ func fixtureUpdateScrapeConfigPayload(mods ...func(*observability.UpdateScrapeCo HonorLabels: utils.Ptr(true), HonorTimeStamps: utils.Ptr(true), MetricsPath: utils.Ptr("/metrics"), - MetricsRelabelConfigs: &[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{ + MetricsRelabelConfigs: &[]observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInner{ { - Action: utils.Ptr("replace"), + Action: observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInnerGetActionAttributeType(utils.Ptr("replace")), Modulus: utils.Ptr(1.0), Regex: utils.Ptr("regex"), Replacement: utils.Ptr("replacement"), @@ -113,7 +113,7 @@ func fixtureUpdateScrapeConfigPayload(mods ...func(*observability.UpdateScrapeCo "key2": []string{}, }, SampleLimit: utils.Ptr(1.0), - Scheme: utils.Ptr("scheme"), + Scheme: observability.UPDATESCRAPECONFIGPAYLOADSCHEME_HTTP.Ptr(), ScrapeInterval: utils.Ptr("interval"), ScrapeTimeout: utils.Ptr("timeout"), StaticConfigs: &[]observability.UpdateScrapeConfigPayloadStaticConfigsInner{ @@ -125,7 +125,7 @@ func fixtureUpdateScrapeConfigPayload(mods ...func(*observability.UpdateScrapeCo Targets: &[]string{"target"}, }, }, - TlsConfig: &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{ + TlsConfig: &observability.PartialUpdateScrapeConfigsRequestInnerHttpSdConfigsInnerOauth2TlsConfig{ InsecureSkipVerify: utils.Ptr(true), }, } @@ -426,13 +426,13 @@ func TestMapMetricsRelabelConfig(t *testing.T) { tests := []struct { description string config *[]observability.MetricsRelabelConfig - expected *[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner + expected *[]observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInner }{ { description: "base case", config: &[]observability.MetricsRelabelConfig{ { - Action: utils.Ptr("replace"), + Action: observability.METRICSRELABELCONFIGACTION_REPLACE.Ptr(), Modulus: utils.Int64Ptr(1), Regex: utils.Ptr("regex"), Replacement: utils.Ptr("replacement"), @@ -441,9 +441,9 @@ func TestMapMetricsRelabelConfig(t *testing.T) { TargetLabel: utils.Ptr("targetLabel"), }, }, - expected: &[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{ + expected: &[]observability.PartialUpdateScrapeConfigsRequestInnerMetricsRelabelConfigsInner{ { - Action: utils.Ptr("replace"), + Action: observability.PARTIALUPDATESCRAPECONFIGSREQUESTINNERMETRICSRELABELCONFIGSINNERACTION_REPLACE.Ptr(), Modulus: utils.Float64Ptr(1.0), Regex: utils.Ptr("regex"), Replacement: utils.Ptr("replacement"), @@ -540,7 +540,7 @@ func TestMapBasicAuth(t *testing.T) { tests := []struct { description string auth *observability.BasicAuth - expected *observability.CreateScrapeConfigPayloadBasicAuth + expected *observability.PartialUpdateScrapeConfigsRequestInnerBasicAuth }{ { description: "base case", @@ -548,7 +548,7 @@ func TestMapBasicAuth(t *testing.T) { Username: utils.Ptr("username"), Password: utils.Ptr("password"), }, - expected: &observability.CreateScrapeConfigPayloadBasicAuth{ + expected: &observability.PartialUpdateScrapeConfigsRequestInnerBasicAuth{ Username: utils.Ptr("username"), Password: utils.Ptr("password"), }, @@ -556,7 +556,7 @@ func TestMapBasicAuth(t *testing.T) { { description: "empty data", auth: &observability.BasicAuth{}, - expected: &observability.CreateScrapeConfigPayloadBasicAuth{}, + expected: &observability.PartialUpdateScrapeConfigsRequestInnerBasicAuth{}, }, { description: "nil", @@ -585,21 +585,21 @@ func TestMapTlsConfig(t *testing.T) { tests := []struct { description string config *observability.TLSConfig - expected *observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig + expected *observability.PartialUpdateScrapeConfigsRequestInnerHttpSdConfigsInnerOauth2TlsConfig }{ { description: "base case", config: &observability.TLSConfig{ InsecureSkipVerify: utils.Ptr(true), }, - expected: &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{ + expected: &observability.PartialUpdateScrapeConfigsRequestInnerHttpSdConfigsInnerOauth2TlsConfig{ InsecureSkipVerify: utils.Ptr(true), }, }, { description: "empty data", config: &observability.TLSConfig{}, - expected: &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{}, + expected: &observability.PartialUpdateScrapeConfigsRequestInnerHttpSdConfigsInnerOauth2TlsConfig{}, }, { description: "nil", diff --git a/internal/pkg/services/opensearch/client/client.go b/internal/pkg/services/opensearch/client/client.go index ce7a8f188..fb7d218a3 100644 --- a/internal/pkg/services/opensearch/client/client.go +++ b/internal/pkg/services/opensearch/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/opensearch" ) -func ConfigureClient(p *print.Printer) (*opensearch.APIClient, error) { - var err error - var apiClient *opensearch.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.OpenSearchCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = opensearch.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*opensearch.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.OpenSearchCustomEndpointKey), true, genericclient.CreateApiClient[*opensearch.APIClient](opensearch.NewAPIClient)) } diff --git a/internal/pkg/services/postgresflex/client/client.go b/internal/pkg/services/postgresflex/client/client.go index d40584a4f..d5b77761f 100644 --- a/internal/pkg/services/postgresflex/client/client.go +++ b/internal/pkg/services/postgresflex/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" ) -func ConfigureClient(p *print.Printer) (*postgresflex.APIClient, error) { - var err error - var apiClient *postgresflex.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.PostgresFlexCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = postgresflex.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*postgresflex.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.PostgresFlexCustomEndpointKey), true, genericclient.CreateApiClient[*postgresflex.APIClient](postgresflex.NewAPIClient)) } diff --git a/internal/pkg/services/rabbitmq/client/client.go b/internal/pkg/services/rabbitmq/client/client.go index a4a7778ac..df717b305 100644 --- a/internal/pkg/services/rabbitmq/client/client.go +++ b/internal/pkg/services/rabbitmq/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/rabbitmq" ) -func ConfigureClient(p *print.Printer) (*rabbitmq.APIClient, error) { - var err error - var apiClient *rabbitmq.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.RabbitMQCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = rabbitmq.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*rabbitmq.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.RabbitMQCustomEndpointKey), true, genericclient.CreateApiClient[*rabbitmq.APIClient](rabbitmq.NewAPIClient)) } diff --git a/internal/pkg/services/redis/client/client.go b/internal/pkg/services/redis/client/client.go index 039da32f7..72c023398 100644 --- a/internal/pkg/services/redis/client/client.go +++ b/internal/pkg/services/redis/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/redis" ) -func ConfigureClient(p *print.Printer) (*redis.APIClient, error) { - var err error - var apiClient *redis.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.RedisCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = redis.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*redis.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.RedisCustomEndpointKey), true, genericclient.CreateApiClient[*redis.APIClient](redis.NewAPIClient)) } diff --git a/internal/pkg/services/resourcemanager/client/client.go b/internal/pkg/services/resourcemanager/client/client.go index ce1ae5620..199b2a2e1 100644 --- a/internal/pkg/services/resourcemanager/client/client.go +++ b/internal/pkg/services/resourcemanager/client/client.go @@ -1,45 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" ) -func ConfigureClient(p *print.Printer) (*resourcemanager.APIClient, error) { - var err error - var apiClient *resourcemanager.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.ResourceManagerEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = resourcemanager.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*resourcemanager.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ResourceManagerEndpointKey), false, genericclient.CreateApiClient[*resourcemanager.APIClient](resourcemanager.NewAPIClient)) } diff --git a/internal/pkg/services/resourcemanager/utils/utils_test.go b/internal/pkg/services/resourcemanager/utils/utils_test.go index bcd0ad2d0..5c79c7354 100644 --- a/internal/pkg/services/resourcemanager/utils/utils_test.go +++ b/internal/pkg/services/resourcemanager/utils/utils_test.go @@ -6,8 +6,9 @@ import ( "testing" "github.com/google/uuid" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) var ( diff --git a/internal/pkg/services/runcommand/client/client.go b/internal/pkg/services/runcommand/client/client.go index 254978f4d..1ecb49f4f 100644 --- a/internal/pkg/services/runcommand/client/client.go +++ b/internal/pkg/services/runcommand/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/runcommand" ) -func ConfigureClient(p *print.Printer) (*runcommand.APIClient, error) { - var err error - var apiClient *runcommand.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.RunCommandCustomEndpointKey) - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } else { - cfgOptions = append(cfgOptions, authCfgOption) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = runcommand.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*runcommand.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.RunCommandCustomEndpointKey), false, genericclient.CreateApiClient[*runcommand.APIClient](runcommand.NewAPIClient)) } diff --git a/internal/pkg/services/runcommand/utils/utils.go b/internal/pkg/services/runcommand/utils/utils.go index d6775f373..358e27c59 100644 --- a/internal/pkg/services/runcommand/utils/utils.go +++ b/internal/pkg/services/runcommand/utils/utils.go @@ -5,13 +5,15 @@ import ( "strings" ) -func ParseScriptParams(params map[string]string) (map[string]string, error) { +func ParseScriptParams(params *map[string]string) (*map[string]string, error) { //nolint:gocritic // flag value is a map pointer if params == nil { return nil, nil } + parsed := map[string]string{} - for k, v := range params { + for k, v := range *params { parsed[k] = v + if k == "script" && strings.HasPrefix(v, "@{") && strings.HasSuffix(v, "}") { // Check if a script file path was specified, like: --params script=@{/tmp/test.sh} fileContents, err := os.ReadFile(v[2 : len(v)-1]) @@ -21,5 +23,6 @@ func ParseScriptParams(params map[string]string) (map[string]string, error) { parsed[k] = string(fileContents) } } - return parsed, nil + + return &parsed, nil } diff --git a/internal/pkg/services/runcommand/utils/utils_test.go b/internal/pkg/services/runcommand/utils/utils_test.go index 5b1d1c69f..320bac18c 100644 --- a/internal/pkg/services/runcommand/utils/utils_test.go +++ b/internal/pkg/services/runcommand/utils/utils_test.go @@ -2,26 +2,34 @@ package utils import ( "testing" + + "github.com/google/go-cmp/cmp" ) func TestParseScriptParams(t *testing.T) { tests := []struct { description string - input map[string]string - expectedOutput map[string]string + input *map[string]string + expectedOutput *map[string]string isValid bool }{ { - "base-ok", - map[string]string{"script": "ls /"}, - map[string]string{"script": "ls /"}, - true, + description: "base-ok", + input: &map[string]string{"script": "ls /"}, + expectedOutput: &map[string]string{"script": "ls /"}, + isValid: true, + }, + { + description: "nil input", + input: nil, + expectedOutput: nil, + isValid: true, }, { - "not-ok-nonexistant-file-specified-for-script", - map[string]string{"script": "@{/some/file/which/does/not/exist/and/thus/fails}"}, - nil, - false, + description: "not-ok-nonexistant-file-specified-for-script", + input: &map[string]string{"script": "@{/some/file/which/does/not/exist/and/thus/fails}"}, + expectedOutput: nil, + isValid: false, }, } @@ -38,8 +46,9 @@ func TestParseScriptParams(t *testing.T) { if !tt.isValid { return } - if output["script"] != tt.expectedOutput["script"] { - t.Errorf("expected output to be %s, got %s", tt.expectedOutput["script"], output["script"]) + diff := cmp.Diff(output, tt.expectedOutput) + if diff != "" { + t.Fatalf("ParseScriptParams() output mismatch (-want +got):\n%s", diff) } }) } diff --git a/internal/pkg/services/secrets-manager/client/client.go b/internal/pkg/services/secrets-manager/client/client.go index ad1d8136f..dfedcb0e3 100644 --- a/internal/pkg/services/secrets-manager/client/client.go +++ b/internal/pkg/services/secrets-manager/client/client.go @@ -1,46 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" ) -func ConfigureClient(p *print.Printer) (*secretsmanager.APIClient, error) { - var err error - var apiClient *secretsmanager.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - - customEndpoint := viper.GetString(config.SecretsManagerCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = secretsmanager.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*secretsmanager.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.SecretsManagerCustomEndpointKey), true, genericclient.CreateApiClient[*secretsmanager.APIClient](secretsmanager.NewAPIClient)) } diff --git a/internal/pkg/services/secrets-manager/utils/utils_test.go b/internal/pkg/services/secrets-manager/utils/utils_test.go index d79ca49f7..f0d926b0b 100644 --- a/internal/pkg/services/secrets-manager/utils/utils_test.go +++ b/internal/pkg/services/secrets-manager/utils/utils_test.go @@ -6,8 +6,9 @@ import ( "testing" "github.com/google/uuid" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" ) var ( diff --git a/internal/pkg/services/serverbackup/client/client.go b/internal/pkg/services/serverbackup/client/client.go index 589166e13..c8726b392 100644 --- a/internal/pkg/services/serverbackup/client/client.go +++ b/internal/pkg/services/serverbackup/client/client.go @@ -1,47 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/serverbackup" ) -func ConfigureClient(p *print.Printer) (*serverbackup.APIClient, error) { - var err error - var apiClient *serverbackup.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.ServerBackupCustomEndpointKey) - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } else { - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = serverbackup.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*serverbackup.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ServerBackupCustomEndpointKey), true, genericclient.CreateApiClient[*serverbackup.APIClient](serverbackup.NewAPIClient)) } diff --git a/internal/pkg/services/serverbackup/utils/utils_test.go b/internal/pkg/services/serverbackup/utils/utils_test.go index b9ca14087..7262fdd6e 100644 --- a/internal/pkg/services/serverbackup/utils/utils_test.go +++ b/internal/pkg/services/serverbackup/utils/utils_test.go @@ -88,7 +88,7 @@ func TestCanDisableBackupService(t *testing.T) { LastRestoredAt: utils.Ptr("test timestamp"), Name: utils.Ptr("test name"), Size: utils.Ptr(int64(5)), - Status: utils.Ptr("test status"), + Status: serverbackup.BACKUPSTATUS_IN_PROGRESS.Ptr(), VolumeBackups: nil, }, }, diff --git a/internal/pkg/services/serverosupdate/client/client.go b/internal/pkg/services/serverosupdate/client/client.go index 1f1f2033e..a3d324d90 100644 --- a/internal/pkg/services/serverosupdate/client/client.go +++ b/internal/pkg/services/serverosupdate/client/client.go @@ -1,45 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/serverupdate" ) -func ConfigureClient(p *print.Printer) (*serverupdate.APIClient, error) { - var apiClient *serverupdate.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.ServerOsUpdateCustomEndpointKey) - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } else { - cfgOptions = append(cfgOptions, authCfgOption) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = serverupdate.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*serverupdate.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ServerOsUpdateCustomEndpointKey), false, genericclient.CreateApiClient[*serverupdate.APIClient](serverupdate.NewAPIClient)) } diff --git a/internal/pkg/services/service-account/client/client.go b/internal/pkg/services/service-account/client/client.go index b4ba6919d..f7150c892 100644 --- a/internal/pkg/services/service-account/client/client.go +++ b/internal/pkg/services/service-account/client/client.go @@ -1,45 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" ) -func ConfigureClient(p *print.Printer) (*serviceaccount.APIClient, error) { - var err error - var apiClient *serviceaccount.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.ServiceAccountCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = serviceaccount.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*serviceaccount.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ServiceAccountCustomEndpointKey), false, genericclient.CreateApiClient[*serviceaccount.APIClient](serviceaccount.NewAPIClient)) } diff --git a/internal/pkg/services/service-enablement/client/client.go b/internal/pkg/services/service-enablement/client/client.go index 8fad8c06b..6aa7324b1 100644 --- a/internal/pkg/services/service-enablement/client/client.go +++ b/internal/pkg/services/service-enablement/client/client.go @@ -2,46 +2,14 @@ package client import ( "github.com/spf13/viper" - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" ) -func ConfigureClient(p *print.Printer) (*serviceenablement.APIClient, error) { - var err error - var apiClient *serviceenablement.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.ServiceEnablementCustomEndpointKey) - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } else { - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, sdkConfig.WithRegion(region)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = serviceenablement.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*serviceenablement.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.ServiceEnablementCustomEndpointKey), true, genericclient.CreateApiClient[*serviceenablement.APIClient](serviceenablement.NewAPIClient)) } diff --git a/internal/pkg/services/service-enablement/utils/utils.go b/internal/pkg/services/service-enablement/utils/utils.go index a6e29254c..5f1976164 100644 --- a/internal/pkg/services/service-enablement/utils/utils.go +++ b/internal/pkg/services/service-enablement/utils/utils.go @@ -6,7 +6,6 @@ import ( "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" - "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/wait" ) const ( @@ -29,5 +28,5 @@ func ProjectEnabled(ctx context.Context, apiClient ServiceEnablementClient, proj } return false, err } - return *project.State == wait.ServiceStateEnabled, nil + return *project.State == serviceenablement.SERVICESTATUSSTATE_ENABLED, nil } diff --git a/internal/pkg/services/service-enablement/utils/utils_test.go b/internal/pkg/services/service-enablement/utils/utils_test.go index 3c364847a..b898adb69 100644 --- a/internal/pkg/services/service-enablement/utils/utils_test.go +++ b/internal/pkg/services/service-enablement/utils/utils_test.go @@ -5,12 +5,9 @@ import ( "fmt" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - "github.com/google/uuid" "github.com/stackitcloud/stackit-sdk-go/core/oapierror" "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" - "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/wait" ) var ( @@ -45,7 +42,7 @@ func TestProjectEnabled(t *testing.T) { }{ { description: "project enabled", - getProjectResp: &serviceenablement.ServiceStatus{State: utils.Ptr(wait.ServiceStateEnabled)}, + getProjectResp: &serviceenablement.ServiceStatus{State: serviceenablement.SERVICESTATUSSTATE_ENABLED.Ptr()}, isValid: true, expectedOutput: true, }, @@ -57,19 +54,19 @@ func TestProjectEnabled(t *testing.T) { }, { description: "project disabled 1", - getProjectResp: &serviceenablement.ServiceStatus{State: utils.Ptr(wait.ServiceStateEnabling)}, + getProjectResp: &serviceenablement.ServiceStatus{State: serviceenablement.SERVICESTATUSSTATE_ENABLING.Ptr()}, isValid: true, expectedOutput: false, }, { description: "project disabled 2", - getProjectResp: &serviceenablement.ServiceStatus{State: utils.Ptr(wait.ServiceStateDisabled)}, + getProjectResp: &serviceenablement.ServiceStatus{State: serviceenablement.SERVICESTATUSSTATE_DISABLING.Ptr()}, isValid: true, expectedOutput: false, }, { description: "project disabled 3", - getProjectResp: &serviceenablement.ServiceStatus{State: utils.Ptr(wait.ServiceStateDisabling)}, + getProjectResp: &serviceenablement.ServiceStatus{State: serviceenablement.SERVICESTATUSSTATE_DISABLING.Ptr()}, isValid: true, expectedOutput: false, }, diff --git a/internal/pkg/services/sfs/client/client.go b/internal/pkg/services/sfs/client/client.go new file mode 100644 index 000000000..0e9057501 --- /dev/null +++ b/internal/pkg/services/sfs/client/client.go @@ -0,0 +1,15 @@ +package client + +import ( + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/spf13/viper" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*sfs.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.SfsCustomEndpointKey), false, genericclient.CreateApiClient[*sfs.APIClient](sfs.NewAPIClient)) +} diff --git a/internal/pkg/services/sfs/utils/utils.go b/internal/pkg/services/sfs/utils/utils.go new file mode 100644 index 000000000..2507a512e --- /dev/null +++ b/internal/pkg/services/sfs/utils/utils.go @@ -0,0 +1,47 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/sfs" +) + +type SfsClient interface { + GetShareExportPolicyExecute(ctx context.Context, projectId string, region string, policyId string) (*sfs.GetShareExportPolicyResponse, error) + GetShareExecute(ctx context.Context, projectId, region, resourcePoolId, shareId string) (*sfs.GetShareResponse, error) + GetResourcePoolExecute(ctx context.Context, projectId, region, resourcePoolId string) (*sfs.GetResourcePoolResponse, error) +} + +func GetShareName(ctx context.Context, client SfsClient, projectId, region, resourcePoolId, shareId string) (string, error) { + resp, err := client.GetShareExecute(ctx, projectId, region, resourcePoolId, shareId) + if err != nil { + return "", fmt.Errorf("get share: %w", err) + } + if resp != nil && resp.Share != nil && resp.Share.Name != nil { + return *resp.Share.Name, nil + } + return "", nil +} + +func GetExportPolicyName(ctx context.Context, apiClient SfsClient, projectId, region, policyId string) (string, error) { + resp, err := apiClient.GetShareExportPolicyExecute(ctx, projectId, region, policyId) + if err != nil { + return "", fmt.Errorf("get share export policy: %w", err) + } + if resp != nil && resp.ShareExportPolicy != nil && resp.ShareExportPolicy.Name != nil { + return *resp.ShareExportPolicy.Name, nil + } + return "", nil +} + +func GetResourcePoolName(ctx context.Context, client SfsClient, projectId, region, resourcePoolId string) (string, error) { + resp, err := client.GetResourcePoolExecute(ctx, projectId, region, resourcePoolId) + if err != nil { + return "", fmt.Errorf("get resource pool: %w", err) + } + if resp != nil && resp.ResourcePool != nil && resp.ResourcePool.Name != nil { + return *resp.ResourcePool.Name, nil + } + return "", nil +} diff --git a/internal/pkg/services/sfs/utils/utils_test.go b/internal/pkg/services/sfs/utils/utils_test.go new file mode 100644 index 000000000..0f7ef08bb --- /dev/null +++ b/internal/pkg/services/sfs/utils/utils_test.go @@ -0,0 +1,207 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sfs" + + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" +) + +const ( + testShareName = "share-name" + testResourcePoolName = "resource-pool-name" + testExportPolicyName = "export-policy-name" + testSnapshotName = "snapshot-name" + testRegion = "eu01" +) + +var ( + testPolicyId = uuid.NewString() + testProjectId = uuid.NewString() +) + +type sfsClientMocked struct { + getShareFails bool + getShareResp *sfs.GetShareResponse + getResourcePoolFails bool + getResourcePoolResp *sfs.GetResourcePoolResponse + getExportPolicyFails bool + getExportPolicyResp *sfs.GetShareExportPolicyResponse +} + +func (s *sfsClientMocked) GetShareExecute(_ context.Context, _, _, _, _ string) (*sfs.GetShareResponse, error) { + if s.getShareFails { + return nil, fmt.Errorf("could not get share") + } + return s.getShareResp, nil +} + +func (s *sfsClientMocked) GetResourcePoolExecute(_ context.Context, _, _, _ string) (*sfs.GetResourcePoolResponse, error) { + if s.getResourcePoolFails { + return nil, fmt.Errorf("could not get resource pool") + } + return s.getResourcePoolResp, nil +} + +func (s *sfsClientMocked) GetShareExportPolicyExecute(_ context.Context, _, _, _ string) (*sfs.GetShareExportPolicyResponse, error) { + if s.getExportPolicyFails { + return nil, fmt.Errorf("could not get export policy") + } + return s.getExportPolicyResp, nil +} + +func TestGetExportPolicyName(t *testing.T) { + tests := []struct { + description string + getExportPolicyResp *sfs.GetShareExportPolicyResponse + getExportPolicyFails bool + isValid bool + expectedOutput string + }{ + { + description: "base", + getExportPolicyResp: &sfs.GetShareExportPolicyResponse{ + ShareExportPolicy: &sfs.GetShareExportPolicyResponseShareExportPolicy{ + Name: utils.Ptr(testExportPolicyName), + }, + }, + isValid: true, + expectedOutput: testExportPolicyName, + }, + { + description: "get export policy fails", + getExportPolicyFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &sfsClientMocked{ + getExportPolicyFails: tt.getExportPolicyFails, + getExportPolicyResp: tt.getExportPolicyResp, + } + + output, err := GetExportPolicyName(context.Background(), client, testProjectId, testRegion, testPolicyId) + + 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 TestGetShareName(t *testing.T) { + tests := []struct { + description string + getShareResp *sfs.GetShareResponse + getShareFails bool + isValid bool + expectedOutput string + }{ + { + description: "base", + getShareResp: &sfs.GetShareResponse{ + Share: &sfs.GetShareResponseShare{ + Name: utils.Ptr(testShareName), + }, + }, + isValid: true, + expectedOutput: testShareName, + }, + { + description: "get share fails", + getShareFails: true, + isValid: false, + expectedOutput: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &sfsClientMocked{ + getShareFails: tt.getShareFails, + getShareResp: tt.getShareResp, + } + + output, err := GetShareName(context.Background(), client, testProjectId, testRegion, "", "") + + 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 TestGetResourcePoolName(t *testing.T) { + tests := []struct { + description string + getResourcePoolResp *sfs.GetResourcePoolResponse + getResourcePoolFails bool + isValid bool + expectedOutput string + }{ + { + description: "base", + getResourcePoolResp: &sfs.GetResourcePoolResponse{ + ResourcePool: &sfs.GetResourcePoolResponseResourcePool{ + Name: utils.Ptr(testResourcePoolName), + }, + }, + isValid: true, + expectedOutput: testResourcePoolName, + }, + { + description: "get resource pool fails", + getResourcePoolFails: true, + isValid: false, + expectedOutput: "", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &sfsClientMocked{ + getResourcePoolResp: tt.getResourcePoolResp, + getResourcePoolFails: tt.getResourcePoolFails, + } + + output, err := GetResourcePoolName(context.Background(), client, testProjectId, testRegion, "") + + 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/ske/client/client.go b/internal/pkg/services/ske/client/client.go index 788fcb4c3..dd3af9872 100644 --- a/internal/pkg/services/ske/client/client.go +++ b/internal/pkg/services/ske/client/client.go @@ -1,47 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" - "github.com/stackitcloud/stackit-sdk-go/services/ske" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) -func ConfigureClient(p *print.Printer) (*ske.APIClient, error) { - var err error - var apiClient *ske.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.SKECustomEndpointKey) - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } else { - region := viper.GetString(config.RegionKey) - cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion(region)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = ske.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*ske.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.SKECustomEndpointKey), false, genericclient.CreateApiClient[*ske.APIClient](ske.NewAPIClient)) } diff --git a/internal/pkg/services/ske/utils/utils.go b/internal/pkg/services/ske/utils/utils.go index 452d4b584..11b4b3930 100644 --- a/internal/pkg/services/ske/utils/utils.go +++ b/internal/pkg/services/ske/utils/utils.go @@ -6,23 +6,21 @@ import ( "maps" "os" "path/filepath" + "regexp" "strconv" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "k8s.io/client-go/tools/clientcmd" - "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" "golang.org/x/mod/semver" ) const ( - defaultNodepoolAvailabilityZone = "eu01-3" defaultNodepoolCRI = "containerd" - defaultNodepoolMachineType = "b1.2" defaultNodepoolMachineImageName = "flatcar" - defaultNodepoolMaxSurge = 1 defaultNodepoolMaxUnavailable = 0 - defaultNodepoolMaximum = 2 defaultNodepoolMinimum = 1 defaultNodepoolName = "pool-default" defaultNodepoolVolumeType = "storage_premium_perf2" @@ -31,17 +29,12 @@ const ( supportedState = "supported" ) -type SKEClient interface { - ListClustersExecute(ctx context.Context, projectId string) (*ske.ListClustersResponse, error) - ListProviderOptionsExecute(ctx context.Context) (*ske.ProviderOptions, error) -} - -func ClusterExists(ctx context.Context, apiClient SKEClient, projectId, clusterName string) (bool, error) { - clusters, err := apiClient.ListClustersExecute(ctx, projectId) +func ClusterExists(ctx context.Context, apiClient ske.DefaultAPI, projectId, region, clusterName string) (bool, error) { + clusters, err := apiClient.ListClusters(ctx, projectId, region).Execute() if err != nil { return false, fmt.Errorf("list SKE clusters: %w", err) } - for _, cl := range *clusters.Items { + for _, cl := range clusters.Items { if cl.Name != nil && *cl.Name == clusterName { return true, nil } @@ -49,8 +42,8 @@ func ClusterExists(ctx context.Context, apiClient SKEClient, projectId, clusterN return false, nil } -func GetDefaultPayload(ctx context.Context, apiClient SKEClient) (*ske.CreateOrUpdateClusterPayload, error) { - resp, err := apiClient.ListProviderOptionsExecute(ctx) +func GetDefaultPayload(ctx context.Context, apiClient ske.DefaultAPI, region string) (*ske.CreateOrUpdateClusterPayload, error) { + resp, err := apiClient.ListProviderOptions(ctx, region).Execute() if err != nil { return nil, fmt.Errorf("get SKE provider options: %w", err) } @@ -67,70 +60,91 @@ func GetDefaultPayload(ctx context.Context, apiClient SKEClient) (*ske.CreateOrU payload := &ske.CreateOrUpdateClusterPayload{ Extensions: &ske.Extension{ Acl: &ske.ACL{ - AllowedCidrs: &[]string{}, - Enabled: utils.Ptr(false), + AllowedCidrs: []string{}, + Enabled: false, }, }, Kubernetes: payloadKubernetes, - Nodepools: &[]ske.Nodepool{ + Nodepools: []ske.Nodepool{ *payloadNodepool, }, } return payload, nil } -func getDefaultPayloadKubernetes(resp *ske.ProviderOptions) (*ske.Kubernetes, error) { - output := &ske.Kubernetes{} +func getDefaultPayloadKubernetes(resp *ske.ProviderOptions) (ske.Kubernetes, error) { + output := ske.Kubernetes{} if resp.KubernetesVersions == nil { - return nil, fmt.Errorf("no supported Kubernetes version found") + return ske.Kubernetes{}, fmt.Errorf("no supported Kubernetes version found") } foundKubernetesVersion := false - versions := *resp.KubernetesVersions + 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 output.Version != "" { + oldSemVer := fmt.Sprintf("v%s", output.Version) + newSemVer := fmt.Sprintf("v%s", version.GetVersion()) if semver.Compare(newSemVer, oldSemVer) != 1 { continue } } foundKubernetesVersion = true - output.Version = version.Version + output.Version = version.GetVersion() } if !foundKubernetesVersion { - return nil, fmt.Errorf("no supported Kubernetes version found") + return ske.Kubernetes{}, fmt.Errorf("no supported Kubernetes version found") } return output, nil } func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) { + if len(resp.AvailabilityZones) == 0 { + return nil, fmt.Errorf("no availability zones found") + } + var availabilityZones []string + for i := range resp.AvailabilityZones { + azName := resp.AvailabilityZones[i].GetName() + // don't include availability zones like eu01-m, eu02-m, not all flavors are available there + if !regexp.MustCompile(`\w{2}\d{2}-m`).MatchString(azName) { + availabilityZones = append(availabilityZones, azName) + } + } + + if len(resp.MachineTypes) == 0 { + return nil, fmt.Errorf("no machine types found") + } + azLen := len(availabilityZones) + if azLen > 1000 { + // check against a very large number to avoid gosec warning + return nil, fmt.Errorf("invalid number of availability zones") + } + machineType := resp.MachineTypes[0].GetName() + output := &ske.Nodepool{ - AvailabilityZones: &[]string{ - defaultNodepoolAvailabilityZone, - }, + AvailabilityZones: availabilityZones, Cri: &ske.CRI{ Name: utils.Ptr(defaultNodepoolCRI), }, - Machine: &ske.Machine{ - Type: utils.Ptr(defaultNodepoolMachineType), - Image: &ske.Image{ - Name: utils.Ptr(defaultNodepoolMachineImageName), + Machine: ske.Machine{ + Type: machineType, + Image: ske.Image{ + Name: defaultNodepoolMachineImageName, }, }, - MaxSurge: utils.Ptr(int64(defaultNodepoolMaxSurge)), - MaxUnavailable: utils.Ptr(int64(defaultNodepoolMaxUnavailable)), - Maximum: utils.Ptr(int64(defaultNodepoolMaximum)), - Minimum: utils.Ptr(int64(defaultNodepoolMinimum)), - Name: utils.Ptr(defaultNodepoolName), - Volume: &ske.Volume{ + // there must be as many nodes as availability zones are given + MaxSurge: utils.Ptr(int32(azLen)), + MaxUnavailable: utils.Ptr(int32(defaultNodepoolMaxUnavailable)), + Maximum: int32(azLen), + Minimum: int32(defaultNodepoolMinimum), + Name: defaultNodepoolName, + Volume: ske.Volume{ Type: utils.Ptr(defaultNodepoolVolumeType), - Size: utils.Ptr(int64(defaultNodepoolVolumeSize)), + Size: int32(defaultNodepoolVolumeSize), }, } @@ -139,7 +153,7 @@ func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) return nil, fmt.Errorf("no supported image versions found") } foundImageVersion := false - images := *resp.MachineImages + images := resp.MachineImages for i := range images { image := images[i] if *image.Name != defaultNodepoolMachineImageName { @@ -148,7 +162,7 @@ func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) if image.Versions == nil { continue } - versions := *image.Versions + versions := image.Versions for j := range versions { version := versions[j] if *version.State != supportedState { @@ -156,12 +170,12 @@ func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) } // Check if default CRI is supported - if version.Cri == nil || len(*version.Cri) == 0 { + if len(version.Cri) == 0 { continue } criSupported := false - for k := range *version.Cri { - cri := (*version.Cri)[k] + for k := range version.Cri { + cri := version.Cri[k] if *cri.Name == defaultNodepoolCRI { criSupported = true break @@ -171,8 +185,8 @@ func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) continue } - if output.Machine.Image.Version != nil { - oldSemVer := fmt.Sprintf("v%s", *output.Machine.Image.Version) + if output.Machine.Image.Version != "" { + oldSemVer := fmt.Sprintf("v%s", output.Machine.Image.Version) newSemVer := fmt.Sprintf("v%s", *version.Version) if semver.Compare(newSemVer, oldSemVer) != 1 { continue @@ -180,7 +194,7 @@ func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) } foundImageVersion = true - output.Machine.Image.Version = version.Version + output.Machine.Image.Version = version.GetVersion() } } if !foundImageVersion { @@ -284,8 +298,12 @@ func WriteConfigFile(configPath, data string) error { return nil } -// GetDefaultKubeconfigPath returns the default location for the kubeconfig file. +// GetDefaultKubeconfigPath returns the default location for the kubeconfig file or the value of KUBECONFIG if set. func GetDefaultKubeconfigPath() (string, error) { + if kubeconfigEnv := os.Getenv("KUBECONFIG"); kubeconfigEnv != "" { + return kubeconfigEnv, nil + } + userHome, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("get user home directory: %w", err) diff --git a/internal/pkg/services/ske/utils/utils_test.go b/internal/pkg/services/ske/utils/utils_test.go index e19c16116..23f8adbac 100644 --- a/internal/pkg/services/ske/utils/utils_test.go +++ b/internal/pkg/services/ske/utils/utils_test.go @@ -7,12 +7,13 @@ import ( "path/filepath" "testing" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" "k8s.io/client-go/tools/clientcmd" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/google/go-cmp/cmp" "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/services/ske" + ske "github.com/stackitcloud/stackit-sdk-go/services/ske/v2api" ) var ( @@ -63,26 +64,7 @@ users: ` ) -type skeClientMocked struct { - listClustersFails bool - listClustersResp *ske.ListClustersResponse - listProviderOptionsFails bool - listProviderOptionsResp *ske.ProviderOptions -} - -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 -} +const testRegion = "eu01" func TestClusterExists(t *testing.T) { tests := []struct { @@ -94,19 +76,19 @@ func TestClusterExists(t *testing.T) { }{ { description: "cluster exists", - getClustersResp: &ske.ListClustersResponse{Items: &[]ske.Cluster{{Name: utils.Ptr(testClusterName)}}}, + 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)}}}, + 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")}}}, + getClustersResp: &ske.ListClustersResponse{Items: []ske.Cluster{{Name: utils.Ptr("some-cluster")}, {Name: utils.Ptr("some-other-cluster")}}}, isValid: true, expectedExists: false, }, @@ -119,12 +101,16 @@ func TestClusterExists(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - client := &skeClientMocked{ - listClustersFails: tt.getClustersFails, - listClustersResp: tt.getClustersResp, + client := &ske.DefaultAPIServiceMock{ + ListClustersExecuteMock: utils.Ptr(func(_ ske.ApiListClustersRequest) (*ske.ListClustersResponse, error) { + if tt.getClustersFails { + return nil, fmt.Errorf("could not list clusters") + } + return tt.getClustersResp, nil + }), } - exists, err := ClusterExists(context.Background(), client, testProjectId, testClusterName) + exists, err := ClusterExists(context.Background(), client, testProjectId, testRegion, testClusterName) if tt.isValid && err != nil { t.Errorf("failed on valid input") @@ -144,7 +130,18 @@ func TestClusterExists(t *testing.T) { func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOptions { providerOptions := &ske.ProviderOptions{ - KubernetesVersions: &[]ske.KubernetesVersion{ + AvailabilityZones: []ske.AvailabilityZone{ + {Name: utils.Ptr("eu01-m")}, + {Name: utils.Ptr("eu01-1")}, + {Name: utils.Ptr("eu01-2")}, + {Name: utils.Ptr("eu01-3")}, + }, + MachineTypes: []ske.MachineType{ + { + Name: utils.Ptr("b1.2"), + }, + }, + KubernetesVersions: []ske.KubernetesVersion{ { State: utils.Ptr("supported"), Version: utils.Ptr("1.2.3"), @@ -158,16 +155,16 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt Version: utils.Ptr("4.4.4"), }, }, - MachineImages: &[]ske.MachineImage{ + MachineImages: []ske.MachineImage{ { Name: utils.Ptr("flatcar"), - Versions: &[]ske.MachineImageVersion{ + Versions: []ske.MachineImageVersion{ { State: utils.Ptr("supported"), Version: utils.Ptr("1.2.3"), - Cri: &[]ske.CRI{ + Cri: []ske.CRI{ { - Name: utils.Ptr("not-containerd"), + Name: utils.Ptr("docker"), }, { Name: utils.Ptr("containerd"), @@ -177,9 +174,9 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt { State: utils.Ptr("supported"), Version: utils.Ptr("3.2.1"), - Cri: &[]ske.CRI{ + Cri: []ske.CRI{ { - Name: utils.Ptr("not-containerd"), + Name: utils.Ptr("docker"), }, { Name: utils.Ptr("containerd"), @@ -190,11 +187,11 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt }, { Name: utils.Ptr("not-flatcar"), - Versions: &[]ske.MachineImageVersion{ + Versions: []ske.MachineImageVersion{ { State: utils.Ptr("supported"), Version: utils.Ptr("4.4.4"), - Cri: &[]ske.CRI{ + Cri: []ske.CRI{ { Name: utils.Ptr("containerd"), }, @@ -204,7 +201,7 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt }, { Name: utils.Ptr("flatcar"), - Versions: &[]ske.MachineImageVersion{ + Versions: []ske.MachineImageVersion{ { State: utils.Ptr("supported"), Version: utils.Ptr("4.4.4"), @@ -213,11 +210,11 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt }, { Name: utils.Ptr("flatcar"), - Versions: &[]ske.MachineImageVersion{ + Versions: []ske.MachineImageVersion{ { State: utils.Ptr("not-supported"), Version: utils.Ptr("4.4.4"), - Cri: &[]ske.CRI{ + Cri: []ske.CRI{ { Name: utils.Ptr("containerd"), }, @@ -227,13 +224,13 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt }, { Name: utils.Ptr("flatcar"), - Versions: &[]ske.MachineImageVersion{ + Versions: []ske.MachineImageVersion{ { State: utils.Ptr("supported"), Version: utils.Ptr("4.4.4"), - Cri: &[]ske.CRI{ + Cri: []ske.CRI{ { - Name: utils.Ptr("not-containerd"), + Name: utils.Ptr("docker"), }, }, }, @@ -251,36 +248,38 @@ func fixtureGetDefaultPayload(mods ...func(*ske.CreateOrUpdateClusterPayload)) * payload := &ske.CreateOrUpdateClusterPayload{ Extensions: &ske.Extension{ Acl: &ske.ACL{ - AllowedCidrs: &[]string{}, - Enabled: utils.Ptr(false), + AllowedCidrs: []string{}, + Enabled: false, }, }, - Kubernetes: &ske.Kubernetes{ - Version: utils.Ptr("3.2.1"), + Kubernetes: ske.Kubernetes{ + Version: "3.2.1", }, - Nodepools: &[]ske.Nodepool{ + Nodepools: []ske.Nodepool{ { - AvailabilityZones: &[]string{ + AvailabilityZones: []string{ + "eu01-1", + "eu01-2", "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"), + Machine: ske.Machine{ + Type: "b1.2", + Image: ske.Image{ + Version: "3.2.1", + Name: "flatcar", }, }, - MaxSurge: utils.Ptr(int64(1)), - MaxUnavailable: utils.Ptr(int64(0)), - Maximum: utils.Ptr(int64(2)), - Minimum: utils.Ptr(int64(1)), - Name: utils.Ptr("pool-default"), - Volume: &ske.Volume{ + MaxSurge: utils.Ptr(int32(3)), + MaxUnavailable: utils.Ptr(int32(0)), + Maximum: int32(3), + Minimum: int32(1), + Name: "pool-default", + Volume: ske.Volume{ Type: utils.Ptr("storage_premium_perf2"), - Size: utils.Ptr(int64(50)), + Size: int32(50), }, }, }, @@ -310,6 +309,34 @@ func TestGetDefaultPayload(t *testing.T) { listProviderOptionsFails: true, isValid: false, }, + { + description: "availability zones nil", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.AvailabilityZones = nil + }), + isValid: false, + }, + { + description: "no availability zones", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.AvailabilityZones = []ske.AvailabilityZone{} + }), + isValid: false, + }, + { + description: "machine types nil", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.MachineTypes = nil + }), + isValid: false, + }, + { + description: "no machine types", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.MachineTypes = []ske.MachineType{} + }), + isValid: false, + }, { description: "no Kubernetes versions 1", listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { @@ -320,14 +347,14 @@ func TestGetDefaultPayload(t *testing.T) { { description: "no Kubernetes versions 2", listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { - po.KubernetesVersions = &[]ske.KubernetesVersion{} + po.KubernetesVersions = []ske.KubernetesVersion{} }), isValid: false, }, { description: "no supported Kubernetes versions", listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { - po.KubernetesVersions = &[]ske.KubernetesVersion{ + po.KubernetesVersions = []ske.KubernetesVersion{ { State: utils.Ptr("not-supported"), Version: utils.Ptr("1.2.3"), @@ -339,7 +366,7 @@ func TestGetDefaultPayload(t *testing.T) { { description: "no machine images 1", listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { - po.MachineImages = &[]ske.MachineImage{} + po.MachineImages = []ske.MachineImage{} }), isValid: false, }, @@ -353,7 +380,7 @@ func TestGetDefaultPayload(t *testing.T) { { description: "no machine image versions 1", listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { - po.MachineImages = &[]ske.MachineImage{ + po.MachineImages = []ske.MachineImage{ { Name: utils.Ptr("image-1"), Versions: nil, @@ -365,10 +392,10 @@ func TestGetDefaultPayload(t *testing.T) { { description: "no machine image versions 2", listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { - po.MachineImages = &[]ske.MachineImage{ + po.MachineImages = []ske.MachineImage{ { Name: utils.Ptr("image-1"), - Versions: &[]ske.MachineImageVersion{}, + Versions: []ske.MachineImageVersion{}, }, } }), @@ -377,10 +404,10 @@ func TestGetDefaultPayload(t *testing.T) { { description: "no supported machine image versions", listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { - po.MachineImages = &[]ske.MachineImage{ + po.MachineImages = []ske.MachineImage{ { Name: utils.Ptr("image-1"), - Versions: &[]ske.MachineImageVersion{ + Versions: []ske.MachineImageVersion{ { State: utils.Ptr("not-supported"), Version: utils.Ptr("1.2.3"), @@ -395,12 +422,16 @@ func TestGetDefaultPayload(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - client := &skeClientMocked{ - listProviderOptionsFails: tt.listProviderOptionsFails, - listProviderOptionsResp: tt.listProviderOptionsResp, + client := &ske.DefaultAPIServiceMock{ + ListProviderOptionsExecuteMock: utils.Ptr(func(_ ske.ApiListProviderOptionsRequest) (*ske.ProviderOptions, error) { + if tt.listProviderOptionsFails { + return nil, fmt.Errorf("could not list provider options") + } + return tt.listProviderOptionsResp, nil + }), } - output, err := GetDefaultPayload(context.Background(), client) + output, err := GetDefaultPayload(context.Background(), client, testRegion) if tt.isValid && err != nil { t.Errorf("failed on valid input") @@ -681,6 +712,8 @@ func TestGetDefaultKubeconfigPath(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { + // prevent test from failing if user has set the environment variable + t.Setenv("KUBECONFIG", "") output, err := GetDefaultKubeconfigPath() if err != nil { @@ -696,3 +729,48 @@ func TestGetDefaultKubeconfigPath(t *testing.T) { }) } } + +func TestGetDefaultKubeconfigPathWithEnvVar(t *testing.T) { + tests := []struct { + description string + kubeconfigEnvVar string + expected string + userHome string + }{ + { + description: "base", + kubeconfigEnvVar: "~/.kube/custom/config", + expected: "~/.kube/custom/config", + userHome: "/home/test-user", + }, + { + description: "return user home when environment var is empty", + kubeconfigEnvVar: "", + expected: "/home/test-user/.kube/config", + userHome: "/home/test-user", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + // Setup environment variables + err := os.Setenv("KUBECONFIG", tt.kubeconfigEnvVar) + if err != nil { + t.Errorf("could not set KUBECONFIG environment variable: %s", err) + } + err = os.Setenv("HOME", tt.userHome) + if err != nil { + t.Errorf("could not set HOME environment variable: %s", err) + } + + output, err := GetDefaultKubeconfigPath() + + if err != nil { + t.Errorf("failed on valid input") + } + if output != tt.expected { + t.Errorf("expected output to be %s, got %s", tt.expected, output) + } + }) + } +} diff --git a/internal/pkg/services/sqlserverflex/client/client.go b/internal/pkg/services/sqlserverflex/client/client.go index 2767ae937..25bbb4ec3 100644 --- a/internal/pkg/services/sqlserverflex/client/client.go +++ b/internal/pkg/services/sqlserverflex/client/client.go @@ -1,45 +1,14 @@ package client import ( - "github.com/stackitcloud/stackit-cli/internal/pkg/auth" "github.com/stackitcloud/stackit-cli/internal/pkg/config" - "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + genericclient "github.com/stackitcloud/stackit-cli/internal/pkg/generic-client" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/spf13/viper" - sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" ) -func ConfigureClient(p *print.Printer) (*sqlserverflex.APIClient, error) { - var err error - var apiClient *sqlserverflex.APIClient - var cfgOptions []sdkConfig.ConfigurationOption - - authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) - if err != nil { - p.Debug(print.ErrorLevel, "configure authentication: %v", err) - return nil, &errors.AuthError{} - } - cfgOptions = append(cfgOptions, authCfgOption) - - customEndpoint := viper.GetString(config.SQLServerFlexCustomEndpointKey) - - if customEndpoint != "" { - cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) - } - - if p.IsVerbosityDebug() { - cfgOptions = append(cfgOptions, - sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), - ) - } - - apiClient, err = sqlserverflex.NewAPIClient(cfgOptions...) - if err != nil { - p.Debug(print.ErrorLevel, "create new API client: %v", err) - return nil, &errors.AuthError{} - } - - return apiClient, nil +func ConfigureClient(p *print.Printer, cliVersion string) (*sqlserverflex.APIClient, error) { + return genericclient.ConfigureClientGeneric(p, cliVersion, viper.GetString(config.SQLServerFlexCustomEndpointKey), false, genericclient.CreateApiClient[*sqlserverflex.APIClient](sqlserverflex.NewAPIClient)) } diff --git a/internal/pkg/spinner/spinner.go b/internal/pkg/spinner/spinner.go index 9c530f1ca..fa76ef2b8 100644 --- a/internal/pkg/spinner/spinner.go +++ b/internal/pkg/spinner/spinner.go @@ -15,7 +15,30 @@ type Spinner struct { done chan bool } -func New(p *print.Printer) *Spinner { +// Run starts a spinner and stops it when f completes +func Run(p *print.Printer, message string, f func() error) error { + _, err := Run2(p, message, func() (struct{}, error) { + err := f() + return struct{}{}, err + }) + return err +} + +// Run2 starts a spinner and stops it when f (result arity 2) completes. +func Run2[T any](p *print.Printer, message string, f func() (T, error)) (T, error) { + var r T + spinner := newSpinner(p) + spinner.start(message) + r, err := f() + if err != nil { + spinner.stopWithError() + return r, err + } + spinner.stop() + return r, nil +} + +func newSpinner(p *print.Printer) *Spinner { return &Spinner{ printer: p, states: []string{"|", "/", "-", "\\"}, @@ -25,18 +48,18 @@ func New(p *print.Printer) *Spinner { } } -func (s *Spinner) Start(message string) { +func (s *Spinner) start(message string) { s.message = message go s.animate() } -func (s *Spinner) Stop() { +func (s *Spinner) stop() { s.done <- true close(s.done) s.printer.Info("\r%s ✓ \n", s.message) } -func (s *Spinner) StopWithError() { +func (s *Spinner) stopWithError() { s.done <- true close(s.done) s.printer.Info("\r%s ✗ \n", s.message) diff --git a/internal/pkg/testutils/assert.go b/internal/pkg/testutils/assert.go new file mode 100755 index 000000000..42c280a71 --- /dev/null +++ b/internal/pkg/testutils/assert.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package testutils + +// Package test provides utilities for validating CLI command test results with +// explicit helpers for error expectations and value comparisons. By splitting +// error and value handling the package keeps assertions simple and removes the +// need for dynamic type checks in every test case. +// +// Example usage: +// +// // Expect a specific error type +// if !test.AssertError(t, run(), &cliErr.FlagValidationError{}) { +// return +// } +// +// // Expect any error +// if !test.AssertError(t, run(), true) { +// return +// } +// +// // Expect error message substring +// if !test.AssertError(t, run(), "not found") { +// return +// } +// +// // Compare complex structs with private fields +// test.AssertValue(t, got, want, test.WithAllowUnexported(MyStruct{})) + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +// AssertError verifies that an observed error satisfies the expected condition. +// +// Returns: +// - bool: True if the test should continue to value checks (i.e., no error occurred). +// +// Behavior: +// 1. If err is nil: +// - If want is nil or false: Success. +// - If want is anything else: Fails test (Expected error but got nil). +// 2. If err is non-nil: +// - If want is nil or false: Fails test (Unexpected error). +// - If want is true: Success (Any error accepted). +// - If want is string: Asserts err.Error() contains the string. +// - If want is error: Asserts errors.Is(err, want) or type match. +func AssertError(t testing.TB, got error, want any) bool { + t.Helper() + + // Case 1: No error occurred + if got == nil { + if want == nil || want == false { + return true + } + t.Errorf("got nil error, want %v", want) + return false + } + + // Case 2: Error occurred + if want == nil || want == false { + t.Errorf("got unexpected error: %v", got) + return false + } + + if want == true { + return false // Error expected and received, stop test + } + + // Handle string error type expectation + if wantStr, ok := want.(string); ok { + if !strings.Contains(got.Error(), wantStr) { + t.Errorf("got error %q, want substring %q", got, wantStr) + } + return false + } + + // Handle specific error type expectation + if wantErr, ok := want.(error); ok { + if checkErrorMatch(got, wantErr) { + return false + } + t.Errorf("got error %v, want %v", got, wantErr) + return false + } + + t.Errorf("invalid want type %T for AssertError", want) + return false +} + +func checkErrorMatch(got, want error) bool { + if errors.Is(got, want) { + return true + } + + // Fallback to type check using errors.As to handle wrapped errors + if want != nil { + typ := reflect.TypeOf(want) + // errors.As requires a pointer to the target type. + // reflect.New(typ) returns *T where T is the type of want. + target := reflect.New(typ).Interface() + if errors.As(got, target) { + return true + } + } + + return false +} + +// DiffFunc compares two values and returns a diff string. An empty string means +// equality. +type DiffFunc func(got, want any) string + +// ValueComparisonOption configures how HandleValueResult applies cmp options or +// diffing strategies. +type ValueComparisonOption func(*valueComparisonConfig) + +type valueComparisonConfig struct { + diffFunc DiffFunc + cmpOptions []cmp.Option +} + +func (config *valueComparisonConfig) getDiffFunc() DiffFunc { + if config.diffFunc != nil { + return config.diffFunc + } + return func(got, want any) string { + return cmp.Diff(got, want, config.cmpOptions...) + } +} + +// WithCmpOptions accumulates cmp.Options used during value comparison. +func WithAssertionCmpOptions(opts ...cmp.Option) ValueComparisonOption { + return func(config *valueComparisonConfig) { + config.cmpOptions = append(config.cmpOptions, opts...) + } +} + +// WithAllowUnexported enables comparison of unexported fields for the provided +// struct types. +func WithAllowUnexported(types ...any) ValueComparisonOption { + return WithAssertionCmpOptions(cmp.AllowUnexported(types...)) +} + +// WithDiffFunc sets a custom diffing function. Providing this option overrides +// the default cmp-based diff logic. +func WithDiffFunc(diffFunc DiffFunc) ValueComparisonOption { + return func(config *valueComparisonConfig) { + config.diffFunc = diffFunc + } +} + +// WithIgnoreFields ignores the specified fields on the provided type during comparison. +// It uses cmpopts.IgnoreFields to ensure type-safe filtering. +func WithIgnoreFields(typ any, names ...string) ValueComparisonOption { + return WithAssertionCmpOptions(cmpopts.IgnoreFields(typ, names...)) +} + +// AssertValue compares two values with cmp.Diff while allowing callers to +// tweak the diff strategy via ValueComparisonOption. A non-empty diff is +// reported as an error containing the diff output. +func AssertValue[T any](t testing.TB, got, want T, opts ...ValueComparisonOption) { + t.Helper() + // Configure comparison options + config := &valueComparisonConfig{} + for _, opt := range opts { + opt(config) + } + // Perform comparison and report diff + diff := config.getDiffFunc()(got, want) + if diff != "" { + t.Errorf("values do not match: %s", diff) + } +} diff --git a/internal/pkg/testutils/assert_test.go b/internal/pkg/testutils/assert_test.go new file mode 100755 index 000000000..ae683a54b --- /dev/null +++ b/internal/pkg/testutils/assert_test.go @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package testutils + +import ( + "errors" + "fmt" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp/cmpopts" +) + +type customError struct{ msg string } + +func (e *customError) Error() string { return e.msg } + +type anotherError struct{ code int } + +func (e *anotherError) Error() string { return fmt.Sprintf("code=%d", e.code) } + +type mockTB struct { + testing.TB + failed bool + msg string +} + +func (m *mockTB) Helper() {} +func (m *mockTB) Errorf(format string, args ...any) { + m.failed = true + m.msg = fmt.Sprintf(format, args...) +} + +func TestAssertError(t *testing.T) { + t.Parallel() + + sentinel := errors.New("sentinel") + + tests := map[string]struct { + got error // The input provided as got to AssertError() + want any // The input provided as want to AssertError() + wantErr bool // Whether this comparison is expected to fail + }{ + "exact match": { + got: &customError{msg: "boom"}, + want: &customError{}, + wantErr: false, + }, + "error string message match": { + got: errors.New("same message"), + want: "same message", + wantErr: false, + }, + "error string mismatch": { + got: errors.New("different"), + want: "same message", + wantErr: true, + }, + "sentinel via errors.Is": { + got: fmt.Errorf("wrap: %w", sentinel), + want: sentinel, + wantErr: false, + }, + "any error (true)": { + got: errors.New("any"), + want: true, + wantErr: false, + }, + "nil expectation (nil)": { + got: nil, + want: nil, + wantErr: false, + }, + "nil expectation (false)": { + got: nil, + want: false, + wantErr: false, + }, + "nil error input with error expectation": { + got: nil, + want: true, + wantErr: true, + }, + "unexpected error (nil want)": { + got: errors.New("unexpected"), + want: nil, + wantErr: true, + }, + "type match without message": { + got: &customError{msg: "alpha"}, + want: &customError{msg: "beta"}, + wantErr: false, + }, + "type mismatch": { + got: &customError{msg: "alpha"}, + want: &anotherError{}, + wantErr: true, + }, + "no error when none expected": { + got: nil, + want: false, + wantErr: false, + }, + "error but want false": { + got: errors.New("boom"), + want: false, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + mock := &mockTB{} + result := AssertError(mock, tt.got, tt.want) + + // if the test failed but we didn't expect it to fail + if mock.failed != tt.wantErr { + t.Fatalf("AssertError() failed = %v, wantErr %v (msg: %s)", mock.failed, tt.wantErr, mock.msg) + } + // if we expected an error the result of AssertError() should be false (this is what AssertError() does in case of error) + if tt.wantErr && result != false { + t.Fatalf("AssertError() returned = %v, want %v", result, tt.wantErr) + } + }) + } +} + +func TestCheckErrorMatch(t *testing.T) { + t.Parallel() + + underlying := &customError{msg: "root"} + wrapped := fmt.Errorf("wrap: %w", underlying) + if !checkErrorMatch(wrapped, &customError{}) { + t.Fatalf("expected wrapped customError to match via errors.As") + } + + notMatch := errors.New("other") + if checkErrorMatch(notMatch, &anotherError{}) { + t.Fatalf("expected mismatch for unrelated error types") + } +} + +func TestAssertValue(t *testing.T) { + t.Parallel() + + type payload struct { + Visible string + hidden int + } + + customDiff := func(got, want any) string { + if reflect.DeepEqual(got, want) { + return "" + } + return "custom diff" + } + + tests := []struct { + name string + got any // The input provided as got to AssertValue() + want any // The input provided as want to AssertValue() + wantErr bool // Whether this comparison is expected to fail + opts []ValueComparisonOption + }{ + { + name: "allow unexported success", + got: payload{Visible: "ok", hidden: 1}, + want: payload{Visible: "ok", hidden: 1}, + opts: []ValueComparisonOption{WithAllowUnexported(payload{})}, + }, + { + name: "allow unexported mismatch", + got: payload{Visible: "oops", hidden: 1}, + want: payload{Visible: "ok", hidden: 1}, + opts: []ValueComparisonOption{WithAllowUnexported(payload{})}, + wantErr: true, + }, + { + name: "cmp options sort", + got: []string{"b", "a", "c"}, + want: []string{"a", "b", "c"}, + opts: []ValueComparisonOption{WithAssertionCmpOptions(cmpopts.SortSlices(func(a, b string) bool { return a < b }))}, + }, + { + name: "custom diff mismatch", + got: 1, + want: 2, + opts: []ValueComparisonOption{WithDiffFunc(customDiff)}, + wantErr: true, + }, + { + name: "default diff success", + got: 42, + want: 42, + }, + { + name: "default diff mismatch", + got: 1, + want: 2, + wantErr: true, + }, + { + name: "diff func overrides cmp options", + got: []string{"b"}, + want: []string{"a"}, + opts: []ValueComparisonOption{ + WithAssertionCmpOptions(cmpopts.SortSlices(func(a, b string) bool { return a < b })), + WithDiffFunc(func(_, _ any) string { return "" }), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mock := &mockTB{} + AssertValue(mock, tt.got, tt.want, tt.opts...) + + // if the test failed but we didn't expect it to fail + if mock.failed != tt.wantErr { + t.Fatalf("AssertValue failed = %v, want %v (msg: %s)", mock.failed, tt.wantErr, mock.msg) + } + }) + } +} diff --git a/internal/pkg/testutils/options.go b/internal/pkg/testutils/options.go new file mode 100644 index 000000000..03d6dab2a --- /dev/null +++ b/internal/pkg/testutils/options.go @@ -0,0 +1,16 @@ +package testutils + +import "github.com/google/go-cmp/cmp" + +type Option struct { + cmpOptions []cmp.Option +} + +type TestingOption func(options *Option) error + +func WithCmpOptions(cmpOptions ...cmp.Option) TestingOption { + return func(options *Option) error { + options.cmpOptions = append(options.cmpOptions, cmpOptions...) + return nil + } +} diff --git a/internal/pkg/testutils/parse_input.go b/internal/pkg/testutils/parse_input.go new file mode 100755 index 000000000..df961b8b8 --- /dev/null +++ b/internal/pkg/testutils/parse_input.go @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package testutils + +import ( + "testing" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +// ParseInputTestCase aggregates all required elements to exercise a CLI parseInput +// function. It centralizes the common flag setup, validation, and result +// assertions used throughout the edge command test suites. +type ParseInputTestCase[T any] struct { + Name string + // Args simulates positional arguments passed to the command. + Args []string + // Flags sets simple single-value flags. + Flags map[string]string + // RepeatFlags sets flags that can be specified multiple times (e.g. slice flags). + RepeatFlags map[string][]string + WantModel T + WantErr any + CmdFactory func(*types.CmdParams) *cobra.Command + // ParseInputFunc is the function under test. It must accept the printer, command, and args. + ParseInputFunc func(*print.Printer, *cobra.Command, []string) (T, error) +} + +// ParseInputCaseOption allows configuring the test execution behavior. +type ParseInputCaseOption func(*parseInputCaseConfig) + +type parseInputCaseConfig struct { + cmpOpts []ValueComparisonOption +} + +// WithParseInputCmpOptions sets custom comparison options for AssertValue. +func WithParseInputCmpOptions(opts ...ValueComparisonOption) ParseInputCaseOption { + return func(cfg *parseInputCaseConfig) { + cfg.cmpOpts = append(cfg.cmpOpts, opts...) + } +} + +func defaultParseInputCaseConfig() *parseInputCaseConfig { + return &parseInputCaseConfig{} +} + +// RunParseInputCase executes a single parse-input test case using the provided +// configuration. It mirrors the typical table-driven pattern while removing the +// boilerplate repeated across tests. The helper short-circuits as soon as an +// expected error is encountered. +func RunParseInputCase[T any](t *testing.T, tc ParseInputTestCase[T], opts ...ParseInputCaseOption) { + t.Helper() + + cfg := defaultParseInputCaseConfig() + for _, opt := range opts { + opt(cfg) + } + + if tc.CmdFactory == nil { + t.Fatalf("parse input case %q missing CmdFactory", tc.Name) + } + if tc.ParseInputFunc == nil { + t.Fatalf("parse input case %q missing ParseInputFunc", tc.Name) + } + + printer := print.NewPrinter() + cmd := tc.CmdFactory(&types.CmdParams{Printer: printer}) + if cmd == nil { + t.Fatalf("parse input case %q produced nil command", tc.Name) + } + if printer.Cmd == nil { + printer.Cmd = cmd + } + + if err := globalflags.Configure(cmd.Flags()); err != nil { + t.Fatalf("configure global flags: %v", err) + } + + // Set regular flag values. + for flag, value := range tc.Flags { + if err := cmd.Flags().Set(flag, value); err != nil { + AssertError(t, err, tc.WantErr) + return + } + } + + // Set repeated flag values. + for flag, values := range tc.RepeatFlags { + for _, value := range values { + if err := cmd.Flags().Set(flag, value); err != nil { + AssertError(t, err, tc.WantErr) + return + } + } + } + + // Test cobra argument validation. + if err := cmd.ValidateArgs(tc.Args); err != nil { + AssertError(t, err, tc.WantErr) + return + } + + // Test cobra required flags validation. + if err := cmd.ValidateRequiredFlags(); err != nil { + AssertError(t, err, tc.WantErr) + return + } + + // Test cobra flag group validation. + if err := cmd.ValidateFlagGroups(); err != nil { + AssertError(t, err, tc.WantErr) + return + } + + // Test parse input function. + got, err := tc.ParseInputFunc(printer, cmd, tc.Args) + if !AssertError(t, err, tc.WantErr) { + return + } + + AssertValue(t, got, tc.WantModel, cfg.cmpOpts...) +} diff --git a/internal/pkg/testutils/parse_input_test.go b/internal/pkg/testutils/parse_input_test.go new file mode 100755 index 000000000..6b5d6c36b --- /dev/null +++ b/internal/pkg/testutils/parse_input_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG + +package testutils + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/types" +) + +type parseInputTestModel struct { + Value string + Args []string + RepeatValue []string + hidden string +} + +func newTestCmdFactory(flagSetup func(*cobra.Command)) func(*types.CmdParams) *cobra.Command { + return func(*types.CmdParams) *cobra.Command { + cmd := &cobra.Command{Use: "test"} + if flagSetup != nil { + flagSetup(cmd) + } + return cmd + } +} + +func TestRunParseInputCase(t *testing.T) { + sentinel := errors.New("parse failed") + tests := []struct { + name string + flagSetup func(*cobra.Command) + flags map[string]string + repeatFlags map[string][]string + args []string + cmpOpts []ParseInputCaseOption + wantModel *parseInputTestModel + wantErr any + parseFunc func(*print.Printer, *cobra.Command, []string) (*parseInputTestModel, error) + expectParseCall bool + }{ + { + name: "success", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().String("name", "", "") + }, + flags: map[string]string{"name": "edge"}, + cmpOpts: []ParseInputCaseOption{WithParseInputCmpOptions(WithAllowUnexported(parseInputTestModel{}))}, + wantModel: &parseInputTestModel{Value: "edge", hidden: "protected"}, + parseFunc: func(_ *print.Printer, cmd *cobra.Command, _ []string) (*parseInputTestModel, error) { + val, _ := cmd.Flags().GetString("name") + return &parseInputTestModel{Value: val, hidden: "protected"}, nil + }, + expectParseCall: true, + }, + { + name: "flag set failure", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().Int("count", 0, "") + }, + flags: map[string]string{"count": "invalid"}, + wantErr: "invalid syntax", + parseFunc: func(_ *print.Printer, _ *cobra.Command, _ []string) (*parseInputTestModel, error) { + return &parseInputTestModel{}, nil + }, + expectParseCall: false, + }, + { + name: "flag group validation", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().String("first", "", "") + cmd.Flags().String("second", "", "") + cmd.MarkFlagsRequiredTogether("first", "second") + }, + flags: map[string]string{"first": "only"}, + wantErr: "must all be set", + parseFunc: func(_ *print.Printer, _ *cobra.Command, _ []string) (*parseInputTestModel, error) { + return &parseInputTestModel{}, nil + }, + expectParseCall: false, + }, + { + name: "parse func error", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().Bool("ok", false, "") + }, + flags: map[string]string{"ok": "true"}, + wantErr: sentinel, + parseFunc: func(_ *print.Printer, _ *cobra.Command, _ []string) (*parseInputTestModel, error) { + return nil, sentinel + }, + expectParseCall: true, + }, + { + name: "args success", + flagSetup: func(cmd *cobra.Command) { + cmd.Args = cobra.ExactArgs(1) + }, + args: []string{"arg1"}, + cmpOpts: []ParseInputCaseOption{WithParseInputCmpOptions(WithAllowUnexported(parseInputTestModel{}))}, + wantModel: &parseInputTestModel{Args: []string{"arg1"}}, + parseFunc: func(_ *print.Printer, _ *cobra.Command, args []string) (*parseInputTestModel, error) { + return &parseInputTestModel{Args: args}, nil + }, + expectParseCall: true, + }, + { + name: "args validation failure", + flagSetup: func(cmd *cobra.Command) { + cmd.Args = cobra.NoArgs + }, + args: []string{"arg1"}, + wantErr: "unknown command", + parseFunc: func(_ *print.Printer, _ *cobra.Command, _ []string) (*parseInputTestModel, error) { + return &parseInputTestModel{}, nil + }, + expectParseCall: false, + }, + { + name: "repeat flags success", + flagSetup: func(cmd *cobra.Command) { + cmd.Flags().StringSlice("tags", []string{}, "") + }, + repeatFlags: map[string][]string{"tags": {"tag1", "tag2"}}, + cmpOpts: []ParseInputCaseOption{WithParseInputCmpOptions(WithAllowUnexported(parseInputTestModel{}))}, + wantModel: &parseInputTestModel{RepeatValue: []string{"tag1", "tag2"}}, + parseFunc: func(_ *print.Printer, cmd *cobra.Command, _ []string) (*parseInputTestModel, error) { + val, _ := cmd.Flags().GetStringSlice("tags") + return &parseInputTestModel{RepeatValue: val}, nil + }, + expectParseCall: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmdFactory := newTestCmdFactory(tt.flagSetup) + var parseCalled bool + parseFn := tt.parseFunc + if parseFn == nil { + parseFn = func(*print.Printer, *cobra.Command, []string) (*parseInputTestModel, error) { + return &parseInputTestModel{}, nil + } + } + + RunParseInputCase(t, ParseInputTestCase[*parseInputTestModel]{ + Name: tt.name, + Flags: tt.flags, + RepeatFlags: tt.repeatFlags, + Args: tt.args, + WantModel: tt.wantModel, + WantErr: tt.wantErr, + CmdFactory: cmdFactory, + ParseInputFunc: func(pr *print.Printer, cmd *cobra.Command, args []string) (*parseInputTestModel, error) { + parseCalled = true + return parseFn(pr, cmd, args) + }, + }, tt.cmpOpts...) + + if parseCalled != tt.expectParseCall { + t.Fatalf("parseCalled = %v, expect %v", parseCalled, tt.expectParseCall) + } + }) + } +} diff --git a/internal/pkg/testutils/testutils.go b/internal/pkg/testutils/testutils.go new file mode 100644 index 000000000..d11a7ed6d --- /dev/null +++ b/internal/pkg/testutils/testutils.go @@ -0,0 +1,122 @@ +package testutils + +import ( + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/types" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/cobra" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +// TestParseInput centralizes the logic to test a combination of inputs (arguments, flags) for a cobra command +func TestParseInput[T any](t *testing.T, cmdFactory func(*types.CmdParams) *cobra.Command, parseInputFunc func(*print.Printer, *cobra.Command, []string) (T, error), expectedModel T, argValues []string, flagValues map[string]string, isValid bool) { + t.Helper() + TestParseInputWithAdditionalFlags(t, cmdFactory, parseInputFunc, expectedModel, argValues, flagValues, map[string][]string{}, isValid) +} + +// TestParseInputWithAdditionalFlags centralizes the logic to test a combination of inputs (arguments, flags) for a cobra command. +// It allows to pass multiple instances of a single flag to the cobra command using the `additionalFlagValues` parameter. +func TestParseInputWithAdditionalFlags[T any](t *testing.T, cmdFactory func(*types.CmdParams) *cobra.Command, parseInputFunc func(*print.Printer, *cobra.Command, []string) (T, error), expectedModel T, argValues []string, flagValues map[string]string, additionalFlagValues map[string][]string, isValid bool) { + TestParseInputWithOptions(t, cmdFactory, parseInputFunc, expectedModel, argValues, flagValues, additionalFlagValues, isValid, nil) +} + +func TestParseInputWithOptions[T any](t *testing.T, cmdFactory func(*types.CmdParams) *cobra.Command, parseInputFunc func(*print.Printer, *cobra.Command, []string) (T, error), expectedModel T, argValues []string, flagValues map[string]string, additionalFlagValues map[string][]string, isValid bool, testingOptions []TestingOption) { + opts := Option{} + for _, option := range testingOptions { + err := option(&opts) + if err != nil { + t.Errorf("Configuring testing options: %v", err) + return + } + } + + p := print.NewPrinter() + cmd := cmdFactory(&types.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + // set regular flag values + for flag, value := range flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + // set additional flag values + for flag, values := range additionalFlagValues { + for _, value := range values { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + } + + if cmd.PreRun != nil { + // can be used for dynamic flag configuration + cmd.PreRun(cmd, argValues) + } + + if cmd.PreRunE != nil { + err := cmd.PreRunE(cmd, argValues) + if err != nil { + if !isValid { + return + } + t.Fatalf("error in PreRunE: %v", err) + } + } + + err = cmd.ValidateArgs(argValues) + if err != nil { + if !isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInputFunc(p, cmd, argValues) + if err != nil { + if !isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, expectedModel, opts.cmpOptions...) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } +} diff --git a/internal/pkg/types/cmd_params.go b/internal/pkg/types/cmd_params.go new file mode 100644 index 000000000..e221ac7bb --- /dev/null +++ b/internal/pkg/types/cmd_params.go @@ -0,0 +1,10 @@ +package types + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/print" +) + +type CmdParams struct { + Printer *print.Printer + CliVersion string +} diff --git a/internal/pkg/utils/strings.go b/internal/pkg/utils/strings.go index 401287fa1..64817f4ee 100644 --- a/internal/pkg/utils/strings.go +++ b/internal/pkg/utils/strings.go @@ -1,6 +1,8 @@ package utils import ( + "fmt" + "slices" "strings" "unicode/utf8" ) @@ -26,6 +28,23 @@ func JoinStringKeysPtr(m map[string]any, sep string) string { return JoinStringKeys(m, sep) } +// JoinStringMap concatenates the key-value pairs of a string map, key and value separated by keyValueSeparator, key value pairs separated by separator. +func JoinStringMap(m map[string]string, keyValueSeparator, separator string) string { + if m == nil { + return "" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + parts := make([]string, 0, len(m)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s%s%s", k, keyValueSeparator, m[k])) + } + return strings.Join(parts, separator) +} + // JoinStringPtr concatenates the strings of a string slice pointer, each separatore by the // [sep] string. func JoinStringPtr(vals *[]string, sep string) string { diff --git a/internal/pkg/utils/strings_test.go b/internal/pkg/utils/strings_test.go index a7fb023bc..6f0279045 100644 --- a/internal/pkg/utils/strings_test.go +++ b/internal/pkg/utils/strings_test.go @@ -30,3 +30,39 @@ func TestTruncate(t *testing.T) { }) } } + +func TestJoinStringMap(t *testing.T) { + tests := []struct { + name string + input map[string]string + want string + }{ + { + name: "nil map", + input: nil, + want: "", + }, + { + name: "empty map", + input: map[string]string{}, + want: "", + }, + { + name: "single element", + input: map[string]string{"key1": "value1"}, + want: "key1=value1", + }, + { + name: "multiple elements", + input: map[string]string{"key1": "value1", "key2": "value2"}, + want: "key1=value1, key2=value2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := JoinStringMap(tt.input, "=", ", "); got != tt.want { + t.Errorf("JoinStringMap() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/pkg/utils/utils.go b/internal/pkg/utils/utils.go index 5649155cd..0cb50a3d4 100644 --- a/internal/pkg/utils/utils.go +++ b/internal/pkg/utils/utils.go @@ -11,6 +11,9 @@ import ( "github.com/inhies/go-bytesize" "github.com/spf13/cobra" "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) @@ -118,9 +121,142 @@ func PtrByteSizeDefault(size *int64, defaultValue string) string { return bytesize.New(float64(*size)).String() } +// PtrGigaByteSizeDefault return the value of an int64 pointer to a string representation of gigabytes. If the pointer is nil, +// it returns the [defaultValue]. +func PtrGigaByteSizeDefault(size *int64, defaultValue string) string { + if size == nil { + return defaultValue + } + return (bytesize.New(float64(*size)) * bytesize.GB).String() +} + // Base64Encode encodes a []byte to a base64 representation as string func Base64Encode(message []byte) string { b := make([]byte, base64.StdEncoding.EncodedLen(len(message))) base64.StdEncoding.Encode(b, message) return string(b) } + +func UserAgentConfigOption(cliVersion string) sdkConfig.ConfigurationOption { + return sdkConfig.WithUserAgent(fmt.Sprintf("stackit-cli/%s", cliVersion)) +} + +// ConvertStringMapToInterfaceMap converts a map[string]string to a pointer to map[string]interface{}. +// Returns nil if the input map is empty. +// +//nolint:gocritic // Linter wants to have a non-pointer type for the map, but this would mean a nil check has to be done before every usage of this func. +func ConvertStringMapToInterfaceMap(m *map[string]string) *map[string]interface{} { + if m == nil || len(*m) == 0 { + return nil + } + result := make(map[string]interface{}, len(*m)) + for k, v := range *m { + result[k] = v + } + return &result +} + +// Base64Bytes implements yaml.Marshaler to convert []byte to base64 strings +// ref: https://carlosbecker.com/posts/go-custom-marshaling +type Base64Bytes []byte + +// MarshalYAML implements yaml.Marshaler +func (b Base64Bytes) MarshalYAML() (interface{}, error) { + if len(b) == 0 { + return "", nil + } + return base64.StdEncoding.EncodeToString(b), nil +} + +type Base64PatchedServer struct { + Id *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Status *string `json:"status,omitempty"` + AvailabilityZone *string `json:"availabilityZone,omitempty"` + BootVolume *iaas.ServerBootVolume `json:"bootVolume,omitempty"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + ErrorMessage *string `json:"errorMessage,omitempty"` + PowerStatus *string `json:"powerStatus,omitempty"` + AffinityGroup *string `json:"affinityGroup,omitempty"` + ImageId *string `json:"imageId,omitempty"` + KeypairName *string `json:"keypairName,omitempty"` + MachineType *string `json:"machineType,omitempty"` + Labels *map[string]interface{} `json:"labels,omitempty"` + LaunchedAt *time.Time `json:"launchedAt,omitempty"` + MaintenanceWindow *iaas.ServerMaintenance `json:"maintenanceWindow,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Networking *iaas.ServerNetworking `json:"networking,omitempty"` + Nics *[]iaas.ServerNetwork `json:"nics,omitempty"` + SecurityGroups *[]string `json:"securityGroups,omitempty"` + ServiceAccountMails *[]string `json:"serviceAccountMails,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + UserData *Base64Bytes `json:"userData,omitempty"` + Volumes *[]string `json:"volumes,omitempty"` +} + +// ConvertToBase64PatchedServer converts an iaas.Server to Base64PatchedServer +// This is a temporary workaround to get the desired base64 encoded yaml output for userdata +// and will be replaced by a fix in the Go-SDK +// ref: https://jira.schwarz/browse/STACKITSDK-246 +func ConvertToBase64PatchedServer(server *iaas.Server) *Base64PatchedServer { + if server == nil { + return nil + } + + var userData *Base64Bytes + if server.UserData != nil { + userData = Ptr(Base64Bytes(*server.UserData)) + } + + return &Base64PatchedServer{ + Id: server.Id, + Name: server.Name, + Status: server.Status, + AvailabilityZone: server.AvailabilityZone, + BootVolume: server.BootVolume, + CreatedAt: server.CreatedAt, + ErrorMessage: server.ErrorMessage, + PowerStatus: server.PowerStatus, + AffinityGroup: server.AffinityGroup, + ImageId: server.ImageId, + KeypairName: server.KeypairName, + MachineType: server.MachineType, + Labels: server.Labels, + LaunchedAt: server.LaunchedAt, + MaintenanceWindow: server.MaintenanceWindow, + Metadata: server.Metadata, + Networking: server.Networking, + Nics: server.Nics, + SecurityGroups: server.SecurityGroups, + ServiceAccountMails: server.ServiceAccountMails, + UpdatedAt: server.UpdatedAt, + UserData: userData, + Volumes: server.Volumes, + } +} + +// ConvertToBase64PatchedServers converts a slice of iaas.Server to a slice of Base64PatchedServer +// This is a temporary workaround to get the desired base64 encoded yaml output for userdata +// and will be replaced by a fix in the Go-SDK +// ref: https://jira.schwarz/browse/STACKITSDK-246 +func ConvertToBase64PatchedServers(servers []iaas.Server) []Base64PatchedServer { + if servers == nil { + return nil + } + + result := make([]Base64PatchedServer, len(servers)) + for i := range servers { + result[i] = *ConvertToBase64PatchedServer(&servers[i]) + } + + return result +} + +// GetSliceFromPointer returns the value of a pointer to a slice of type T. +// If the pointer is nil, it returns an empty slice. +func GetSliceFromPointer[T any](s *[]T) []T { + if s == nil || *s == nil { + return []T{} + } + return *s +} diff --git a/internal/pkg/utils/utils_test.go b/internal/pkg/utils/utils_test.go index 6ef165d9b..0b5a656af 100644 --- a/internal/pkg/utils/utils_test.go +++ b/internal/pkg/utils/utils_test.go @@ -1,9 +1,15 @@ package utils import ( + "reflect" "testing" + "time" + + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" "github.com/spf13/viper" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" ) @@ -108,3 +114,480 @@ func TestValidateURLDomain(t *testing.T) { }) } } + +func TestUserAgentConfigOption(t *testing.T) { + type args struct { + providerVersion string + } + tests := []struct { + name string + args args + want sdkConfig.ConfigurationOption + }{ + { + name: "TestUserAgentConfigOption", + args: args{ + providerVersion: "1.0.0", + }, + want: sdkConfig.WithUserAgent("stackit-cli/1.0.0"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clientConfigActual := sdkConfig.Configuration{} + err := tt.want(&clientConfigActual) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + + clientConfigExpected := sdkConfig.Configuration{} + err = UserAgentConfigOption(tt.args.providerVersion)(&clientConfigExpected) + if err != nil { + t.Errorf("error configuring client: %v", err) + } + + if !reflect.DeepEqual(clientConfigActual, clientConfigExpected) { + t.Errorf("UserAgentConfigOption() = %v, want %v", clientConfigActual, clientConfigExpected) + } + }) + } +} + +func TestConvertStringMapToInterfaceMap(t *testing.T) { + tests := []struct { + name string + input *map[string]string + expected *map[string]interface{} + }{ + { + name: "nil input", + input: nil, + expected: nil, + }, + { + name: "empty map", + input: &map[string]string{}, + expected: nil, + }, + { + name: "single key-value pair", + input: &map[string]string{ + "key1": "value1", + }, + expected: &map[string]interface{}{ + "key1": "value1", + }, + }, + { + name: "multiple key-value pairs", + input: &map[string]string{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + expected: &map[string]interface{}{ + "key1": "value1", + "key2": "value2", + "key3": "value3", + }, + }, + { + name: "special characters in values", + input: &map[string]string{ + "key1": "value with spaces", + "key2": "value,with,commas", + "key3": "value\nwith\nnewlines", + }, + expected: &map[string]interface{}{ + "key1": "value with spaces", + "key2": "value,with,commas", + "key3": "value\nwith\nnewlines", + }, + }, + { + name: "empty values", + input: &map[string]string{ + "key1": "", + "key2": "value2", + }, + expected: &map[string]interface{}{ + "key1": "", + "key2": "value2", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertStringMapToInterfaceMap(tt.input) + + // Check if both are nil + if result == nil && tt.expected == nil { + return + } + + // Check if one is nil and other isn't + if (result == nil && tt.expected != nil) || (result != nil && tt.expected == nil) { + t.Errorf("ConvertStringMapToInterfaceMap() = %v, want %v", result, tt.expected) + return + } + + // Compare maps + if len(*result) != len(*tt.expected) { + t.Errorf("ConvertStringMapToInterfaceMap() map length = %d, want %d", len(*result), len(*tt.expected)) + return + } + + for k, v := range *result { + expectedVal, ok := (*tt.expected)[k] + if !ok { + t.Errorf("ConvertStringMapToInterfaceMap() unexpected key %s in result", k) + continue + } + if v != expectedVal { + t.Errorf("ConvertStringMapToInterfaceMap() value for key %s = %v, want %v", k, v, expectedVal) + } + } + }) + } +} + +func TestConvertToBase64PatchedServer(t *testing.T) { + now := time.Now() + userData := []byte("test") + emptyUserData := []byte("") + + tests := []struct { + name string + input *iaas.Server + expected *Base64PatchedServer + }{ + { + name: "nil input", + input: nil, + expected: nil, + }, + { + name: "server with user data", + input: &iaas.Server{ + Id: Ptr("server-123"), + Name: Ptr("test-server"), + Status: Ptr("ACTIVE"), + AvailabilityZone: Ptr("eu01-1"), + MachineType: Ptr("t1.1"), + UserData: &userData, + CreatedAt: &now, + PowerStatus: Ptr("RUNNING"), + AffinityGroup: Ptr("group-1"), + ImageId: Ptr("image-123"), + KeypairName: Ptr("keypair-1"), + }, + expected: &Base64PatchedServer{ + Id: Ptr("server-123"), + Name: Ptr("test-server"), + Status: Ptr("ACTIVE"), + AvailabilityZone: Ptr("eu01-1"), + MachineType: Ptr("t1.1"), + UserData: Ptr(Base64Bytes(userData)), + CreatedAt: &now, + PowerStatus: Ptr("RUNNING"), + AffinityGroup: Ptr("group-1"), + ImageId: Ptr("image-123"), + KeypairName: Ptr("keypair-1"), + }, + }, + { + name: "server with empty user data", + input: &iaas.Server{ + Id: Ptr("server-456"), + Name: Ptr("test-server-2"), + Status: Ptr("STOPPED"), + AvailabilityZone: Ptr("eu01-2"), + MachineType: Ptr("t1.2"), + UserData: &emptyUserData, + }, + expected: &Base64PatchedServer{ + Id: Ptr("server-456"), + Name: Ptr("test-server-2"), + Status: Ptr("STOPPED"), + AvailabilityZone: Ptr("eu01-2"), + MachineType: Ptr("t1.2"), + UserData: Ptr(Base64Bytes(emptyUserData)), + }, + }, + { + name: "server without user data", + input: &iaas.Server{ + Id: Ptr("server-789"), + Name: Ptr("test-server-3"), + Status: Ptr("CREATING"), + AvailabilityZone: Ptr("eu01-3"), + MachineType: Ptr("t1.3"), + UserData: nil, + }, + expected: &Base64PatchedServer{ + Id: Ptr("server-789"), + Name: Ptr("test-server-3"), + Status: Ptr("CREATING"), + AvailabilityZone: Ptr("eu01-3"), + MachineType: Ptr("t1.3"), + UserData: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertToBase64PatchedServer(tt.input) + + if result == nil && tt.expected == nil { + return + } + + if (result == nil && tt.expected != nil) || (result != nil && tt.expected == nil) { + t.Errorf("ConvertToBase64PatchedServer() = %v, want %v", result, tt.expected) + return + } + + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ConvertToBase64PatchedServer() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestConvertToBase64PatchedServers(t *testing.T) { + now := time.Now() + userData1 := []byte("test1") + userData2 := []byte("test2") + emptyUserData := []byte("") + + tests := []struct { + name string + input []iaas.Server + expected []Base64PatchedServer + }{ + { + name: "nil input", + input: nil, + expected: nil, + }, + { + name: "empty slice", + input: []iaas.Server{}, + expected: []Base64PatchedServer{}, + }, + { + name: "single server with user data", + input: []iaas.Server{ + { + Id: Ptr("server-1"), + Name: Ptr("test-server-1"), + Status: Ptr("ACTIVE"), + MachineType: Ptr("t1.1"), + AvailabilityZone: Ptr("eu01-1"), + UserData: &userData1, + CreatedAt: &now, + }, + }, + expected: []Base64PatchedServer{ + { + Id: Ptr("server-1"), + Name: Ptr("test-server-1"), + Status: Ptr("ACTIVE"), + MachineType: Ptr("t1.1"), + AvailabilityZone: Ptr("eu01-1"), + UserData: Ptr(Base64Bytes(userData1)), + CreatedAt: &now, + }, + }, + }, + { + name: "multiple servers mixed", + input: []iaas.Server{ + { + Id: Ptr("server-1"), + Name: Ptr("test-server-1"), + Status: Ptr("ACTIVE"), + MachineType: Ptr("t1.1"), + AvailabilityZone: Ptr("eu01-1"), + UserData: &userData1, + CreatedAt: &now, + }, + { + Id: Ptr("server-2"), + Name: Ptr("test-server-2"), + Status: Ptr("STOPPED"), + MachineType: Ptr("t1.2"), + AvailabilityZone: Ptr("eu01-2"), + UserData: &userData2, + }, + { + Id: Ptr("server-3"), + Name: Ptr("test-server-3"), + Status: Ptr("CREATING"), + MachineType: Ptr("t1.3"), + AvailabilityZone: Ptr("eu01-3"), + UserData: &emptyUserData, + }, + { + Id: Ptr("server-4"), + Name: Ptr("test-server-4"), + Status: Ptr("ERROR"), + MachineType: Ptr("t1.4"), + AvailabilityZone: Ptr("eu01-4"), + UserData: nil, + }, + }, + expected: []Base64PatchedServer{ + { + Id: Ptr("server-1"), + Name: Ptr("test-server-1"), + Status: Ptr("ACTIVE"), + MachineType: Ptr("t1.1"), + AvailabilityZone: Ptr("eu01-1"), + UserData: Ptr(Base64Bytes(userData1)), + CreatedAt: &now, + }, + { + Id: Ptr("server-2"), + Name: Ptr("test-server-2"), + Status: Ptr("STOPPED"), + MachineType: Ptr("t1.2"), + AvailabilityZone: Ptr("eu01-2"), + UserData: Ptr(Base64Bytes(userData2)), + }, + { + Id: Ptr("server-3"), + Name: Ptr("test-server-3"), + Status: Ptr("CREATING"), + MachineType: Ptr("t1.3"), + AvailabilityZone: Ptr("eu01-3"), + UserData: Ptr(Base64Bytes(emptyUserData)), + }, + { + Id: Ptr("server-4"), + Name: Ptr("test-server-4"), + Status: Ptr("ERROR"), + MachineType: Ptr("t1.4"), + AvailabilityZone: Ptr("eu01-4"), + UserData: nil, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ConvertToBase64PatchedServers(tt.input) + + if result == nil && tt.expected == nil { + return + } + + if (result == nil && tt.expected != nil) || (result != nil && tt.expected == nil) { + t.Errorf("ConvertToBase64PatchedServers() = %v, want %v", result, tt.expected) + return + } + + if len(result) != len(tt.expected) { + t.Errorf("ConvertToBase64PatchedServers() length = %d, want %d", len(result), len(tt.expected)) + return + } + + for i, server := range result { + if !reflect.DeepEqual(server, tt.expected[i]) { + t.Errorf("ConvertToBase64PatchedServers() [%d] = %v, want %v", i, server, tt.expected[i]) + } + } + }) + } +} + +func TestBase64Bytes_MarshalYAML(t *testing.T) { + tests := []struct { + name string + input Base64Bytes + expected interface{} + }{ + { + name: "empty bytes", + input: Base64Bytes{}, + expected: "", + }, + { + name: "nil bytes", + input: Base64Bytes(nil), + expected: "", + }, + { + name: "simple text", + input: Base64Bytes("test"), + expected: "dGVzdA==", + }, + { + name: "special characters", + input: Base64Bytes("test@#$%"), + expected: "dGVzdEAjJCU=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tt.input.MarshalYAML() + if err != nil { + t.Errorf("MarshalYAML() error = %v", err) + return + } + if result != tt.expected { + t.Errorf("MarshalYAML() = %v, want %v", result, tt.expected) + } + }) + } +} +func TestGetSliceFromPointer(t *testing.T) { + tests := []struct { + name string + input *[]string + expected []string + }{ + { + name: "nil pointer", + input: nil, + expected: []string{}, + }, + { + name: "pointer to nil slice", + input: func() *[]string { + var s []string + return &s + }(), + expected: []string{}, + }, + { + name: "empty slice", + input: &[]string{}, + expected: []string{}, + }, + { + name: "populated slice", + input: &[]string{"item1", "item2"}, + expected: []string{"item1", "item2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetSliceFromPointer(tt.input) + + if result == nil { + t.Errorf("GetSliceFromPointer() = %v, want %v", result, tt.expected) + return + } + + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("GetSliceFromPointer() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index d81181db9..cb2058804 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # This script is used to ensure for PRs the docs are up-to-date via the CI pipeline # Usage: ./check-docs.sh diff --git a/scripts/publish-apt-packages.sh b/scripts/publish-apt-packages.sh index f6ec84174..81aa53cb4 100755 --- a/scripts/publish-apt-packages.sh +++ b/scripts/publish-apt-packages.sh @@ -1,11 +1,9 @@ -#!/bin/bash +#!/usr/bin/env bash # This script is used to publish new packages to the CLI APT repository # Usage: ./publish-apt-packages.sh set -eo pipefail -ROOT_DIR=$(git rev-parse --show-toplevel) - PACKAGES_BUCKET_URL="https://packages.stackit.cloud" PUBLIC_KEY_FILE_PATH="keys/key.gpg" APT_REPO_PATH="apt/cli" @@ -27,7 +25,7 @@ aptly mirror create -config "${APTLY_CONFIG_FILE_PATH}" -keyring="${CUSTOM_KEYRI # Update the mirror to the latest state printf "\n>>> Updating mirror \n" -aptly mirror update -keyring="${CUSTOM_KEYRING_FILE}" current +aptly mirror update -keyring="${CUSTOM_KEYRING_FILE}" -max-tries=5 current # Create a snapshot of the mirror printf "\n>>> Creating snapshop from mirror \n" @@ -51,4 +49,4 @@ aptly snapshot pull -no-remove -architectures="amd64,i386,arm64" current-snapsho # Publish the new snapshot to the remote repo printf "\n>>> Publishing updated snapshot \n" -aptly publish snapshot -keyring="${CUSTOM_KEYRING_FILE}" -gpg-key="${GPG_PRIVATE_KEY_FINGERPRINT}" -passphrase "${GPG_PASSPHRASE}" -config "${APTLY_CONFIG_FILE_PATH}" updated-snapshot "s3:${APT_BUCKET_NAME}:${APT_REPO_PATH}" +aptly publish snapshot -keyring="${CUSTOM_KEYRING_FILE}" -gpg-key="${GPG_PRIVATE_KEY_FINGERPRINT}" -passphrase "${GPG_PASSPHRASE}" -config "${APTLY_CONFIG_FILE_PATH}" updated-snapshot "s3:${APT_BUCKET_NAME}:${APT_REPO_PATH}" \ No newline at end of file diff --git a/scripts/publish-rpm-packages.sh b/scripts/publish-rpm-packages.sh new file mode 100755 index 000000000..d657d1e0d --- /dev/null +++ b/scripts/publish-rpm-packages.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash + +# This script is used to publish new RPM packages to the CLI RPM repository +# Usage: ./publish-rpm-packages.sh +set -eo pipefail + +PACKAGES_BUCKET_URL="https://packages.stackit.cloud" +PUBLIC_KEY_FILE_PATH="keys/key.gpg" +RPM_REPO_PATH="rpm/cli" +RPM_BUCKET_NAME="distribution" +GORELEASER_PACKAGES_FOLDER="dist/" + +# We need to disable the key database daemon (keyboxd) +# This can be done by removing "use-keyboxd" from ~/.gnupg/common.conf (see https://github.com/gpg/gnupg/blob/master/README) +echo -n >~/.gnupg/common.conf + +# Create RPM repository directory structure +printf ">>> Creating RPM repository structure \n" +mkdir -p rpm-repo/x86_64 +mkdir -p rpm-repo/i386 +mkdir -p rpm-repo/aarch64 + +# Copy RPM packages to appropriate architecture directories +printf "\n>>> Copying RPM packages to architecture directories \n" + +# Copy x86_64 packages (amd64) +for rpm_file in "${GORELEASER_PACKAGES_FOLDER}"*_amd64.rpm; do + if [ -f "$rpm_file" ]; then + cp "$rpm_file" rpm-repo/x86_64/ + printf "Copied %s to x86_64/\n" "$(basename "$rpm_file")" + fi +done + +# Copy i386 packages +for rpm_file in "${GORELEASER_PACKAGES_FOLDER}"*_386.rpm; do + if [ -f "$rpm_file" ]; then + cp "$rpm_file" rpm-repo/i386/ + printf "Copied %s to i386/\n" "$(basename "$rpm_file")" + fi +done + +# Copy aarch64 packages (arm64) +for rpm_file in "${GORELEASER_PACKAGES_FOLDER}"*_arm64.rpm; do + if [ -f "$rpm_file" ]; then + cp "$rpm_file" rpm-repo/aarch64/ + printf "Copied %s to aarch64/\n" "$(basename "$rpm_file")" + fi +done + +# Download existing repository content (RPMs and metadata) if it exists +printf "\n>>> Downloading existing repository content \n" +aws s3 sync s3://${RPM_BUCKET_NAME}/${RPM_REPO_PATH}/ rpm-repo/ --endpoint-url "${AWS_ENDPOINT_URL}" --exclude "*.asc" || echo "No existing repository found, creating new one" + +# Create repository metadata for each architecture +printf "\n>>> Creating repository metadata \n" +for arch in x86_64 i386 aarch64; do + if [ -d "rpm-repo/${arch}" ] && [ -n "$(find "rpm-repo/${arch}" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + printf "Creating metadata for %s...\n" "$arch" + + # List what we're working with + file_list=$(find "rpm-repo/${arch}" -maxdepth 1 -type f -exec basename {} \; | tr '\n' ' ') + printf "Files in %s: %s\n" "$arch" "${file_list% }" + + # Create repository metadata + createrepo_c --update rpm-repo/${arch} + + # Sign the repository metadata + printf "Signing repository metadata for %s...\n" "$arch" + # Remove existing signature file if it exists + rm -f rpm-repo/${arch}/repodata/repomd.xml.asc + gpg --batch --pinentry-mode loopback --detach-sign --armor \ + --local-user "${GPG_PRIVATE_KEY_FINGERPRINT}" \ + --passphrase "${GPG_PASSPHRASE}" \ + rpm-repo/${arch}/repodata/repomd.xml + + # Verify the signature was created + if [ -f "rpm-repo/${arch}/repodata/repomd.xml.asc" ]; then + printf "Repository metadata signed successfully for %s\n" "$arch" + else + printf "WARNING: Repository metadata signature not created for %s\n" "$arch" + fi + else + printf "No packages found for %s, skipping...\n" "$arch" + fi +done + +# Upload the updated repository to S3 in two phases (repodata pointers last) +# clients reading the repo won't see a state where repomd.xml points to files not uploaded yet. +printf "\n>>> Uploading repository to S3 (phase 1: all except repomd*) \n" +aws s3 sync rpm-repo/ s3://${RPM_BUCKET_NAME}/${RPM_REPO_PATH}/ \ + --endpoint-url "${AWS_ENDPOINT_URL}" \ + --delete \ + --exclude "*/repodata/repomd.xml" \ + --exclude "*/repodata/repomd.xml.asc" + +printf "\n>>> Uploading repository to S3 (phase 2: repomd* only) \n" +aws s3 sync rpm-repo/ s3://${RPM_BUCKET_NAME}/${RPM_REPO_PATH}/ \ + --endpoint-url "${AWS_ENDPOINT_URL}" \ + --exclude "*" \ + --include "*/repodata/repomd.xml" \ + --include "*/repodata/repomd.xml.asc" + +# Upload the public key +# Also uploaded in APT publish; intentionally redundant +# Safe to overwrite and ensures updates if APT fails or key changes. +printf "\n>>> Uploading public key \n" +gpg --armor --export "${GPG_PRIVATE_KEY_FINGERPRINT}" > public-key.asc +aws s3 cp public-key.asc s3://${RPM_BUCKET_NAME}/${PUBLIC_KEY_FILE_PATH} --endpoint-url "${AWS_ENDPOINT_URL}" + +printf "\n>>> RPM repository published successfully! \n" +printf "Repository URL: %s/%s/ \n" "$PACKAGES_BUCKET_URL" "$RPM_REPO_PATH" +printf "Public key URL: %s/%s \n" "$PACKAGES_BUCKET_URL" "$PUBLIC_KEY_FILE_PATH" diff --git a/scripts/replace.sh b/scripts/replace.sh index 0c37f4b85..9326b1f72 100755 --- a/scripts/replace.sh +++ b/scripts/replace.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Add replace directives to local files to go.work set -eo pipefail