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
new file mode 100644
index 000000000..7ec96a1b5
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @stackitcloud/developer-tools
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..b9edca504
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,35 @@
+---
+name: Bug report
+about: Report a bug in the STACKIT CLI
+title: ''
+labels: bug
+assignees: ''
+
+---
+
+## Description
+
+*Please add a clear and concise description of what the bug is.*
+
+## Steps to reproduce
+
+
+1. Run `stackit ...`
+2. Run `stackit ...`
+3. ...
+
+## Actual behavior
+
+*Please describe the current behavior of the STACKIT CLI. Don't forget to add detailed information like error messages.*
+
+## Expected behavior
+
+*Please describe the behavior which you would expect from the STACKIT CLI in that case.*
+
+## Environment
+ - OS:
+ - Version of STACKIT CLI (see `stackit --version`): `vX.X.X`
+
+**Additional information**
+
+*Feel free to add any additional information here.*
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..0eb26f937
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,25 @@
+---
+name: Feature request
+about: Suggest an idea for the STACKIT CLI
+title: ''
+labels: enhancement
+assignees: ''
+
+---
+
+## Problem description
+
+*Is your feature request related to a problem? If so, please give us a clear and concise description of what the problem is.
+Example: I'm always frustrated when [...]*
+
+## Proposed solution
+
+*A clear and concise description of what you want to happen.*
+
+## Alternative solutions (optional)
+
+*A clear and concise description of any alternative solutions or features you've considered. (optional)*
+
+## Additional information
+
+*Feel free to add any additional information here.*
diff --git a/.github/actions/build/action.yaml b/.github/actions/build/action.yaml
deleted file mode 100644
index 3601b23f3..000000000
--- a/.github/actions/build/action.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-name: Build
-description: "Build pipeline"
-inputs:
- go-version:
- description: "Go version to install"
- required: true
-runs:
- using: "composite"
- steps:
- - name: Install Go ${{ inputs.go-version }}
- uses: actions/setup-go@v5
- with:
- go-version: ${{ inputs.go-version }}
- - name: Install project tools and dependencies
- shell: bash
- run: make project-tools
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..3a4e09fe6
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,15 @@
+version: 2
+updates:
+ - package-ecosystem: "gomod"
+ 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/images/stackit-logo.png b/.github/images/stackit-logo.png
deleted file mode 100644
index de09c4245..000000000
Binary files a/.github/images/stackit-logo.png and /dev/null differ
diff --git a/.github/images/stackit-logo.svg b/.github/images/stackit-logo.svg
new file mode 100644
index 000000000..68236800e
--- /dev/null
+++ b/.github/images/stackit-logo.svg
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..b01df3feb
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,18 @@
+## Description
+
+
+
+relates to #1234
+
+## Checklist
+
+- [ ] Issue was linked above
+- [ ] Code format was applied: `make fmt`
+- [ ] Examples were added / adjusted (see e.g. [here](https://github.com/stackitcloud/stackit-cli/blob/ef291d1683ca5b0d719ec0a26ecb999a32685117/internal/cmd/ske/cluster/create/create.go#L49-L63))
+- [x] Docs are up-to-date: `make generate-docs` (will be checked by CI)
+- [ ] Unit tests got implemented or updated
+- [x] Unit tests are passing: `make test` (will be checked by CI)
+- [x] No linter issues: `make lint` (will be checked by CI)
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 0889f8fa6..67954c08f 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -1,9 +1,15 @@
name: CI
-on: [pull_request, workflow_dispatch]
+on:
+ pull_request:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
env:
- GO_VERSION: "1.22"
+ CODE_COVERAGE_FILE_NAME: "coverage.out" # must be the same as in Makefile
+ CODE_COVERAGE_ARTIFACT_NAME: "code-coverage"
jobs:
main:
@@ -11,12 +17,56 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
- - name: Build
- uses: ./.github/actions/build
+ uses: actions/checkout@v6
+
+ - name: Install go
+ uses: actions/setup-go@v6
with:
- go-version: ${{ env.GO_VERSION }}
+ go-version-file: "go.mod"
+ cache: true
+
+ - name: "Ensure docs are up-to-date"
+ if: ${{ github.event_name == 'pull_request' }}
+ run: ./scripts/check-docs.sh
+
- name: Lint
run: make lint
+
- name: Test
run: make test
+
+ - name: Archive code coverage results
+ uses: actions/upload-artifact@v6
+ 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@v6
+ 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.2.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 0344e3995..e9377a728 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -21,24 +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:
- - uses: actions/checkout@v4
+ - name: Checkout
+ uses: actions/checkout@v6
with:
# Allow goreleaser to access older tag information.
fetch-depth: 0
- - uses: actions/setup-go@v5
+
+ - name: Install go
+ uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache: true
+
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@v6
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
@@ -46,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
@@ -57,10 +70,9 @@ jobs:
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
SIGNING_CERTIFICATE_BASE64: ${{ secrets.APPLICATION_ID_CERT }}
AUTHKEY_BASE64: ${{ secrets.APPLE_API_KEY }}
- - name: Install Aptly
- run: brew install aptly
- name: Install Snapcraft
- uses: samuelmeuli/action-snapcraft@v2
+ uses: samuelmeuli/action-snapcraft@v3
+
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -68,9 +80,93 @@ jobs:
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@v6
+ 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@v7
+ with:
+ name: goreleaser-dist-temp
+ path: dist
+
+ - name: Install Aptly
+ run: brew install aptly
+
+ - name: Import GPG key
+ uses: crazy-max/ghaction-import-gpg@v6
+ 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@v7
+ 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@v6
+ 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 f4976ea1a..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@v40.1.12
- with:
- configurationFile: .github/renovate.json
- token: ${{ secrets.RENOVATE_TOKEN }}
diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml
new file mode 100644
index 000000000..894da7025
--- /dev/null
+++ b/.github/workflows/stale.yaml
@@ -0,0 +1,37 @@
+name: "Stale"
+on:
+ schedule:
+ # every night at 01:30
+ - cron: "30 1 * * *"
+ # run this workflow if the workflow definition gets changed within a PR
+ pull_request:
+ branches: ["main"]
+ paths: [".github/workflows/stale.yaml"]
+
+env:
+ DAYS_BEFORE_PR_STALE: 7
+ DAYS_BEFORE_PR_CLOSE: 7
+ EXEMPT_PR_LABELS: "ignore-stale"
+
+permissions:
+ issues: write
+ pull-requests: write
+
+jobs:
+ stale:
+ name: "Stale"
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: "Mark old PRs as stale"
+ 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/.gitignore b/.gitignore
index 428e142ad..2cf34eb63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,11 @@ dist/
# OS generated files
.DS_Store
+
+# Go workspace file
+go.work
+go.work.sum
+
+# Test coverage reports
+coverage.out
+coverage.html
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 6e861a4f8..2ef5c2ad1 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
@@ -81,20 +110,14 @@ nfpms:
- 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,16 +125,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
+ 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
@@ -119,12 +145,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
@@ -148,4 +174,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 f88cb3883..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
@@ -13,6 +13,8 @@ $ stackit auth activate-service-account
You can also configure the service account credentials directly in the CLI. To get help and to get a list of the available options run the command with the `-h` flag.
+**_Note:_** There is an optional flag `--only-print-access-token` which can be used to only obtain the access token which prevents writing the credentials to the keyring or into `cli-auth-storage.txt` ([File Location](./README.md#configuration)). This access token can be stored as environment variable (STACKIT_ACCESS_TOKEN) in order to be used for all subsequent commands by default.
+
### Overview
If you don't have a service account, create one in the [STACKIT Portal](https://portal.stackit.cloud/) and assign the necessary permissions to it, e.g. `owner`. There are two ways to authenticate:
@@ -45,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.
@@ -90,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 be0a54346..47d79abda 100644
--- a/CONTRIBUTION.md
+++ b/CONTRIBUTION.md
@@ -19,18 +19,18 @@ Your contribution is welcome! Thank you for your interest in contributing to the
Prerequisites:
-- [`Go`](https://go.dev/doc/install) 1.22+
+- [`Go`](https://go.dev/doc/install) 1.24+
- [`yamllint`](https://yamllint.readthedocs.io/en/stable/quickstart.html)
### Useful Make commands
These commands can be executed from the project root:
-- `make project-tools`: install the required dependencies
- `make build`: compile the CLI and save the binary under _./bin/stackit_
- `make lint`: lint the code
- `make generate-docs`: generate Markdown documentation for every command
- `make test`: run unit tests
+- `make coverage`: create unit test coverage report (output file: `coverage.html`)
### Repository structure
@@ -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 1f0f78ae6..14976fe69 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.
@@ -66,6 +106,80 @@ sudo apt-get update
sudo apt-get install stackit
```
+> If you can't install the `stackit` package due to an expired key, please go back to step `1` to import the latest public key.
+
+#### Nix / NixOS
+
+The STACKIT CLI is available as a [Nix package](https://search.nixos.org/packages?channel=unstable&show=stackit-cli), and can be used via:
+
+```shell
+nix-shell -p stackit-cli
+```
+
+#### Eget
+
+The STACKIT CLI binaries are available via our [GitHub releases](https://github.com/stackitcloud/stackit-cli/releases), you can install them from there using [Eget](https://github.com/zyedidia/eget).
+
+```toml
+# ~/.eget.toml
+["stackitcloud/stackit-cli"]
+asset_filters=["stackit-cli_", "_linux_amd64.tar.gz"]
+```
+
+```shell
+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.
@@ -101,6 +215,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 ab57bb905..836e41ad4 100644
--- a/Makefile
+++ b/Makefile
@@ -1,36 +1,39 @@
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:
@go build -o ./bin/stackit
-# Setup and tool initialization tasks
-project-help:
- @$(SCRIPTS_BASE)/project.sh help
-
-project-tools:
- @$(SCRIPTS_BASE)/project.sh tools
+fmt:
+ @gofmt -s -w .
+ @go tool goimports -w .
# Lint
lint-golangci-lint:
- @echo "Linting with golangci-lint"
- @golangci-lint run ${GOLANG_CI_ARGS}
+ @echo ">> Linting with golangci-lint"
+ @go tool golangci-lint run ${GOLANG_CI_ARGS}
lint-yamllint:
- @echo "Linting with yamllint"
+ @echo ">> Linting with yamllint"
@yamllint -c .yamllint.yaml .
lint: lint-golangci-lint lint-yamllint
# Test
test:
- @echo "Running tests for the CLI application"
- @go test ./... -count=1
+ @echo ">> Running tests for the CLI application"
+ @go test ./... -count=1 -coverprofile=coverage.out
+
+# Test coverage
+coverage:
+ @echo ">> Creating test coverage report for the CLI application"
+ @go test ./... -coverprofile=coverage.out || true
+ @go tool cover -html=coverage.out -o coverage.html
# Generate docs
generate-docs:
- @echo "Generating docs..."
- @go run $(SCRIPTS_BASE)/generate.go
\ No newline at end of file
+ @echo ">> Generating docs..."
+ @go run $(SCRIPTS_BASE)/generate.go
diff --git a/README.md b/README.md
index 95c04687d..8c66fa532 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,35 @@
-
+
-# STACKIT CLI (BETA)
+# STACKIT CLI
[](https://goreportcard.com/report/github.com/stackitcloud/stackit-cli)  [](https://www.apache.org/licenses/LICENSE-2.0)
-Welcome to the [STACKIT](https://www.stackit.de/en) CLI, a command-line interface for the STACKIT services.
+Welcome to the STACKIT CLI, a command-line interface for [STACKIT - The German business cloud](https://www.stackit.de/en).
-This CLI is in a BETA state. More services and functionality will be supported soon.
-Your feedback is appreciated!
+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:
+
+- Projects, including permissions
+- STACKIT Kubernetes Engine clusters
+- Servers
+- DNS zones and record-sets
+- Databases such as PostgreSQL Flex, MongoDB Flex and SQLServer Flex
+
+Your feedback is appreciated!
+Feel free to open [GitHub issues](https://github.com/stackitcloud/stackit-cli) to provide feature requests and bug reports.
## Installation
Please refer to our [installation guide](./INSTALLATION.md) for instructions on how to install and get started using the STACKIT CLI.
+## Documentation
+
+There is some [documentation](./docs/stackit.md) available in the markdown format inside the `docs` directory of the repository.
+
## Usage
A typical command is structured as:
@@ -56,26 +68,28 @@ 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 |
-| ---------------------------------- | ------------------------- | ------------------------- |
-| Argus | `argus` | :white_check_mark: |
-| Infrastructure as a Service (IaaS) | | Will be integrated soon |
-| Authorization | `project`, `organization` | :white_check_mark: |
-| DNS | `dns` | :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: |
-| 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: |
-| Service Account | `service-account` | :white_check_mark: |
-| SQLServer Flex | `beta sqlserverflex` | :white_check_mark: (beta) |
+| 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) |
## Authentication
@@ -174,6 +188,10 @@ If you encounter any issues or have suggestions for improvements, please open an
Your contribution is welcome! For more details on how to contribute, refer to our [contribution guide](./CONTRIBUTION.md).
+## Release creation
+
+See the [release documentation](./RELEASE.md) for further information.
+
## License
Apache 2.0
@@ -184,6 +202,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/RELEASE.md b/RELEASE.md
new file mode 100644
index 000000000..7b701cf6b
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,17 @@
+# Release
+
+## Release cycle
+
+A release should be created at least every 2 weeks.
+
+## Release creation
+
+> [!IMPORTANT]
+> Consider informing / syncing with the team before creating a new release.
+
+1. Check out latest main branch on your machine
+2. Create git tag: `git tag vX.X.X`
+3. Push the git tag: `git push origin --tags`
+4. The [release pipeline](https://github.com/stackitcloud/stackit-cli/actions/workflows/release.yaml) will build the release and publish it on GitHub
+5. Ensure the release was created properly using the [releases page](https://github.com/stackitcloud/stackit-cli/releases)
+
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..049f3f6fe
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,9 @@
+# Reporting Security Issues
+
+**Please do not report security vulnerabilities through public GitHub issues.**
+
+We at STACKIT take security seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
+
+To report a security issue, please send an email to [stackit-security@stackit.de](mailto:stackit-security@stackit.de).
+
+Our team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
diff --git a/docs/stackit.md b/docs/stackit.md
index cb2915ace..d0ddc4554 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]
@@ -20,30 +19,43 @@ stackit [flags]
-h, --help Help for "stackit"
-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")
-v, --version Show "stackit" version
```
### SEE ALSO
-* [stackit argus](./stackit_argus.md) - Provides functionality for Argus
+* [stackit affinity-group](./stackit_affinity-group.md) - Manage server affinity groups
* [stackit auth](./stackit_auth.md) - Authenticates the STACKIT CLI
* [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands
* [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 load-balancer](./stackit_load-balancer.md) - Provides functionality for Load Balancer
* [stackit logme](./stackit_logme.md) - Provides functionality for LogMe
* [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
+* [stackit network-area](./stackit_network-area.md) - Provides functionality for STACKIT Network Area (SNA)
+* [stackit network-interface](./stackit_network-interface.md) - Provides functionality for network interfaces
* [stackit object-storage](./stackit_object-storage.md) - Provides functionality for Object Storage
+* [stackit observability](./stackit_observability.md) - Provides functionality for Observability
* [stackit opensearch](./stackit_opensearch.md) - Provides functionality for OpenSearch
* [stackit organization](./stackit_organization.md) - Manages organizations
* [stackit postgresflex](./stackit_postgresflex.md) - Provides functionality for PostgreSQL Flex
* [stackit project](./stackit_project.md) - Manages projects
+* [stackit public-ip](./stackit_public-ip.md) - Provides functionality for public IPs
+* [stackit quota](./stackit_quota.md) - Manage server quotas
* [stackit rabbitmq](./stackit_rabbitmq.md) - Provides functionality for RabbitMQ
* [stackit redis](./stackit_redis.md) - Provides functionality for Redis
* [stackit secrets-manager](./stackit_secrets-manager.md) - Provides functionality for Secrets Manager
+* [stackit security-group](./stackit_security-group.md) - Manage security groups
+* [stackit server](./stackit_server.md) - Provides functionality for servers
* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts
* [stackit ske](./stackit_ske.md) - Provides functionality for SKE
+* [stackit volume](./stackit_volume.md) - Provides functionality for volumes
diff --git a/docs/stackit_affinity-group.md b/docs/stackit_affinity-group.md
new file mode 100644
index 000000000..9603fe20f
--- /dev/null
+++ b/docs/stackit_affinity-group.md
@@ -0,0 +1,37 @@
+## stackit affinity-group
+
+Manage server affinity groups
+
+### Synopsis
+
+Manage the lifecycle of server affinity groups.
+
+```
+stackit affinity-group [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit affinity-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](./stackit.md) - Manage STACKIT resources using the command line
+* [stackit affinity-group create](./stackit_affinity-group_create.md) - Creates an affinity groups
+* [stackit affinity-group delete](./stackit_affinity-group_delete.md) - Deletes an affinity group
+* [stackit affinity-group describe](./stackit_affinity-group_describe.md) - Show details of an affinity group
+* [stackit affinity-group list](./stackit_affinity-group_list.md) - Lists affinity groups
+
diff --git a/docs/stackit_affinity-group_create.md b/docs/stackit_affinity-group_create.md
new file mode 100644
index 000000000..fb63f39cf
--- /dev/null
+++ b/docs/stackit_affinity-group_create.md
@@ -0,0 +1,42 @@
+## stackit affinity-group create
+
+Creates an affinity groups
+
+### Synopsis
+
+Creates an affinity groups.
+
+```
+stackit affinity-group create [flags]
+```
+
+### Examples
+
+```
+ Create an affinity group with name "AFFINITY_GROUP_NAME" and policy "soft-affinity"
+ $ stackit affinity-group create --name AFFINITY_GROUP_NAME --policy soft-affinity
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit affinity-group create"
+ --name string The name of the affinity group.
+ --policy string The policy for the affinity group. Valid values for the policy are: "hard-affinity", "hard-anti-affinity", "soft-affinity", "soft-anti-affinity"
+```
+
+### 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 affinity-group](./stackit_affinity-group.md) - Manage server affinity groups
+
diff --git a/docs/stackit_affinity-group_delete.md b/docs/stackit_affinity-group_delete.md
new file mode 100644
index 000000000..4baa73768
--- /dev/null
+++ b/docs/stackit_affinity-group_delete.md
@@ -0,0 +1,40 @@
+## stackit affinity-group delete
+
+Deletes an affinity group
+
+### Synopsis
+
+Deletes an affinity group.
+
+```
+stackit affinity-group delete AFFINITY_GROUP [flags]
+```
+
+### Examples
+
+```
+ Delete an affinity group with ID "xxx"
+ $ stackit affinity-group delete xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit affinity-group 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 affinity-group](./stackit_affinity-group.md) - Manage server affinity groups
+
diff --git a/docs/stackit_affinity-group_describe.md b/docs/stackit_affinity-group_describe.md
new file mode 100644
index 000000000..79276bba0
--- /dev/null
+++ b/docs/stackit_affinity-group_describe.md
@@ -0,0 +1,40 @@
+## stackit affinity-group describe
+
+Show details of an affinity group
+
+### Synopsis
+
+Show details of an affinity group.
+
+```
+stackit affinity-group describe AFFINITY_GROUP_ID [flags]
+```
+
+### Examples
+
+```
+ Get details about an affinity group with the ID "xxx"
+ $ stackit affinity-group describe xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit affinity-group 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 affinity-group](./stackit_affinity-group.md) - Manage server affinity groups
+
diff --git a/docs/stackit_affinity-group_list.md b/docs/stackit_affinity-group_list.md
new file mode 100644
index 000000000..ea96c20ac
--- /dev/null
+++ b/docs/stackit_affinity-group_list.md
@@ -0,0 +1,44 @@
+## stackit affinity-group list
+
+Lists affinity groups
+
+### Synopsis
+
+Lists affinity groups.
+
+```
+stackit affinity-group list [flags]
+```
+
+### Examples
+
+```
+ Lists all affinity groups
+ $ stackit affinity-group list
+
+ Lists up to 10 affinity groups
+ $ stackit affinity-group list --limit=10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit affinity-group 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 affinity-group](./stackit_affinity-group.md) - Manage server affinity groups
+
diff --git a/docs/stackit_argus.md b/docs/stackit_argus.md
deleted file mode 100644
index b09e8d344..000000000
--- a/docs/stackit_argus.md
+++ /dev/null
@@ -1,37 +0,0 @@
-## stackit argus
-
-Provides functionality for Argus
-
-### Synopsis
-
-Provides functionality for Argus.
-
-```
-stackit argus [flags]
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus"
-```
-
-### 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
- --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 argus credentials](./stackit_argus_credentials.md) - Provides functionality for Argus credentials
-* [stackit argus grafana](./stackit_argus_grafana.md) - Provides functionality for the Grafana configuration of Argus instances
-* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances
-* [stackit argus plans](./stackit_argus_plans.md) - Lists all Argus service plans
-* [stackit argus scrape-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus
-
diff --git a/docs/stackit_argus_credentials.md b/docs/stackit_argus_credentials.md
deleted file mode 100644
index b8cb24b2d..000000000
--- a/docs/stackit_argus_credentials.md
+++ /dev/null
@@ -1,35 +0,0 @@
-## stackit argus credentials
-
-Provides functionality for Argus credentials
-
-### Synopsis
-
-Provides functionality for Argus credentials.
-
-```
-stackit argus credentials [flags]
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus credentials"
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus](./stackit_argus.md) - Provides functionality for Argus
-* [stackit argus credentials create](./stackit_argus_credentials_create.md) - Creates credentials for an Argus instance.
-* [stackit argus credentials delete](./stackit_argus_credentials_delete.md) - Deletes credentials of an Argus instance
-* [stackit argus credentials list](./stackit_argus_credentials_list.md) - Lists the usernames of all credentials for an Argus instance
-
diff --git a/docs/stackit_argus_credentials_delete.md b/docs/stackit_argus_credentials_delete.md
deleted file mode 100644
index b88f82b44..000000000
--- a/docs/stackit_argus_credentials_delete.md
+++ /dev/null
@@ -1,40 +0,0 @@
-## stackit argus credentials delete
-
-Deletes credentials of an Argus instance
-
-### Synopsis
-
-Deletes credentials of an Argus instance.
-
-```
-stackit argus credentials delete USERNAME [flags]
-```
-
-### Examples
-
-```
- Delete credentials of username "xxx" for Argus instance with ID "yyy"
- $ stackit argus credentials delete xxx --instance-id yyy
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus credentials delete"
- --instance-id string Instance ID
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus credentials](./stackit_argus_credentials.md) - Provides functionality for Argus credentials
-
diff --git a/docs/stackit_argus_credentials_list.md b/docs/stackit_argus_credentials_list.md
deleted file mode 100644
index 889f08e04..000000000
--- a/docs/stackit_argus_credentials_list.md
+++ /dev/null
@@ -1,47 +0,0 @@
-## stackit argus credentials list
-
-Lists the usernames of all credentials for an Argus instance
-
-### Synopsis
-
-Lists the usernames of all credentials for an Argus instance.
-
-```
-stackit argus credentials list [flags]
-```
-
-### Examples
-
-```
- List the usernames of all credentials for an Argus instance with ID "xxx"
- $ stackit argus credentials list --instance-id xxx
-
- List the usernames of all credentials for an Argus instance in JSON format
- $ stackit argus credentials list --instance-id xxx --output-format json
-
- List the usernames of up to 10 credentials for an Argus instance
- $ stackit argus credentials list --instance-id xxx --limit 10
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus credentials list"
- --instance-id string Instance ID
- --limit int Maximum number of entries to list
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus credentials](./stackit_argus_credentials.md) - Provides functionality for Argus credentials
-
diff --git a/docs/stackit_argus_grafana.md b/docs/stackit_argus_grafana.md
deleted file mode 100644
index 3307c0b37..000000000
--- a/docs/stackit_argus_grafana.md
+++ /dev/null
@@ -1,35 +0,0 @@
-## stackit argus grafana
-
-Provides functionality for the Grafana configuration of Argus instances
-
-### Synopsis
-
-Provides functionality for the Grafana configuration of Argus instances.
-
-```
-stackit argus grafana [flags]
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus grafana"
-```
-
-### 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
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus](./stackit_argus.md) - Provides functionality for Argus
-* [stackit argus grafana describe](./stackit_argus_grafana_describe.md) - Shows details of the Grafana configuration of an Argus instance
-* [stackit argus grafana public-read-access](./stackit_argus_grafana_public-read-access.md) - Enable or disable public read access for Grafana in Argus instances
-* [stackit argus grafana single-sign-on](./stackit_argus_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Argus instances
-
diff --git a/docs/stackit_argus_grafana_describe.md b/docs/stackit_argus_grafana_describe.md
deleted file mode 100644
index 53ea3812a..000000000
--- a/docs/stackit_argus_grafana_describe.md
+++ /dev/null
@@ -1,48 +0,0 @@
-## stackit argus grafana describe
-
-Shows details of the Grafana configuration of an Argus instance
-
-### Synopsis
-
-Shows details of the Grafana configuration of an Argus 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 argus grafana describe INSTANCE_ID [flags]
-```
-
-### Examples
-
-```
- Get details of the Grafana configuration of an Argus instance with ID "xxx"
- $ stackit argus credentials describe xxx
-
- Get details of the Grafana configuration of an Argus instance with ID "xxx" and show the initial admin password
- $ stackit argus credentials describe xxx --show-password
-
- Get details of the Grafana configuration of an Argus instance with ID "xxx" in JSON format
- $ stackit argus credentials describe xxx --output-format json
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus grafana describe"
- -s, --show-password Show password in output
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus grafana](./stackit_argus_grafana.md) - Provides functionality for the Grafana configuration of Argus instances
-
diff --git a/docs/stackit_argus_grafana_public-read-access.md b/docs/stackit_argus_grafana_public-read-access.md
deleted file mode 100644
index 963dbec1d..000000000
--- a/docs/stackit_argus_grafana_public-read-access.md
+++ /dev/null
@@ -1,35 +0,0 @@
-## stackit argus grafana public-read-access
-
-Enable or disable public read access for Grafana in Argus instances
-
-### Synopsis
-
-Enable or disable public read access for Grafana in Argus instances.
-When enabled, anyone can access the Grafana dashboards of the instance without logging in. Otherwise, a login is required.
-
-```
-stackit argus grafana public-read-access [flags]
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus grafana public-read-access"
-```
-
-### 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
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus grafana](./stackit_argus_grafana.md) - Provides functionality for the Grafana configuration of Argus instances
-* [stackit argus grafana public-read-access disable](./stackit_argus_grafana_public-read-access_disable.md) - Disables public read access for Grafana on Argus instances
-* [stackit argus grafana public-read-access enable](./stackit_argus_grafana_public-read-access_enable.md) - Enables public read access for Grafana on Argus instances
-
diff --git a/docs/stackit_argus_grafana_public-read-access_disable.md b/docs/stackit_argus_grafana_public-read-access_disable.md
deleted file mode 100644
index b2f232934..000000000
--- a/docs/stackit_argus_grafana_public-read-access_disable.md
+++ /dev/null
@@ -1,40 +0,0 @@
-## stackit argus grafana public-read-access disable
-
-Disables public read access for Grafana on Argus instances
-
-### Synopsis
-
-Disables public read access for Grafana on Argus instances.
-When disabled, a login is required to access the Grafana dashboards of the instance. Otherwise, anyone can access the dashboards.
-
-```
-stackit argus grafana public-read-access disable INSTANCE_ID [flags]
-```
-
-### Examples
-
-```
- Disable public read access for Grafana on an Argus instance with ID "xxx"
- $ stackit argus grafana public-read-access disable xxx
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus grafana public-read-access disable"
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus grafana public-read-access](./stackit_argus_grafana_public-read-access.md) - Enable or disable public read access for Grafana in Argus instances
-
diff --git a/docs/stackit_argus_grafana_public-read-access_enable.md b/docs/stackit_argus_grafana_public-read-access_enable.md
deleted file mode 100644
index 1cbe9579a..000000000
--- a/docs/stackit_argus_grafana_public-read-access_enable.md
+++ /dev/null
@@ -1,40 +0,0 @@
-## stackit argus grafana public-read-access enable
-
-Enables public read access for Grafana on Argus instances
-
-### Synopsis
-
-Enables public read access for Grafana on Argus instances.
-When enabled, anyone can access the Grafana dashboards of the instance without logging in. Otherwise, a login is required.
-
-```
-stackit argus grafana public-read-access enable INSTANCE_ID [flags]
-```
-
-### Examples
-
-```
- Enable public read access for Grafana on an Argus instance with ID "xxx"
- $ stackit argus grafana public-read-access enable xxx
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus grafana public-read-access enable"
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus grafana public-read-access](./stackit_argus_grafana_public-read-access.md) - Enable or disable public read access for Grafana in Argus instances
-
diff --git a/docs/stackit_argus_grafana_single-sign-on.md b/docs/stackit_argus_grafana_single-sign-on.md
deleted file mode 100644
index 37540150e..000000000
--- a/docs/stackit_argus_grafana_single-sign-on.md
+++ /dev/null
@@ -1,35 +0,0 @@
-## stackit argus grafana single-sign-on
-
-Enable or disable single sign-on for Grafana in Argus instances
-
-### Synopsis
-
-Enable or disable single sign-on for Grafana in Argus instances.
-When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.
-
-```
-stackit argus grafana single-sign-on [flags]
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus grafana single-sign-on"
-```
-
-### 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
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus grafana](./stackit_argus_grafana.md) - Provides functionality for the Grafana configuration of Argus instances
-* [stackit argus grafana single-sign-on disable](./stackit_argus_grafana_single-sign-on_disable.md) - Disables single sign-on for Grafana on Argus instances
-* [stackit argus grafana single-sign-on enable](./stackit_argus_grafana_single-sign-on_enable.md) - Enables single sign-on for Grafana on Argus instances
-
diff --git a/docs/stackit_argus_grafana_single-sign-on_disable.md b/docs/stackit_argus_grafana_single-sign-on_disable.md
deleted file mode 100644
index 2766cb326..000000000
--- a/docs/stackit_argus_grafana_single-sign-on_disable.md
+++ /dev/null
@@ -1,40 +0,0 @@
-## stackit argus grafana single-sign-on disable
-
-Disables single sign-on for Grafana on Argus instances
-
-### Synopsis
-
-Disables single sign-on for Grafana on Argus instances.
-When disabled for an instance, the generic OAuth2 authentication is used for that instance.
-
-```
-stackit argus grafana single-sign-on disable INSTANCE_ID [flags]
-```
-
-### Examples
-
-```
- Disable single sign-on for Grafana on an Argus instance with ID "xxx"
- $ stackit argus grafana single-sign-on disable xxx
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus grafana single-sign-on disable"
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus grafana single-sign-on](./stackit_argus_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Argus instances
-
diff --git a/docs/stackit_argus_grafana_single-sign-on_enable.md b/docs/stackit_argus_grafana_single-sign-on_enable.md
deleted file mode 100644
index 33b6ae4e1..000000000
--- a/docs/stackit_argus_grafana_single-sign-on_enable.md
+++ /dev/null
@@ -1,40 +0,0 @@
-## stackit argus grafana single-sign-on enable
-
-Enables single sign-on for Grafana on Argus instances
-
-### Synopsis
-
-Enables single sign-on for Grafana on Argus instances.
-When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.
-
-```
-stackit argus grafana single-sign-on enable INSTANCE_ID [flags]
-```
-
-### Examples
-
-```
- Enable single sign-on for Grafana on an Argus instance with ID "xxx"
- $ stackit argus grafana single-sign-on enable xxx
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus grafana single-sign-on enable"
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus grafana single-sign-on](./stackit_argus_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Argus instances
-
diff --git a/docs/stackit_argus_instance.md b/docs/stackit_argus_instance.md
deleted file mode 100644
index 8683c76eb..000000000
--- a/docs/stackit_argus_instance.md
+++ /dev/null
@@ -1,37 +0,0 @@
-## stackit argus instance
-
-Provides functionality for Argus instances
-
-### Synopsis
-
-Provides functionality for Argus instances.
-
-```
-stackit argus instance [flags]
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus 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
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus](./stackit_argus.md) - Provides functionality for Argus
-* [stackit argus instance create](./stackit_argus_instance_create.md) - Creates an Argus instance
-* [stackit argus instance delete](./stackit_argus_instance_delete.md) - Deletes an Argus instance
-* [stackit argus instance describe](./stackit_argus_instance_describe.md) - Shows details of an Argus instance
-* [stackit argus instance list](./stackit_argus_instance_list.md) - Lists all Argus instances
-* [stackit argus instance update](./stackit_argus_instance_update.md) - Updates an Argus instance
-
diff --git a/docs/stackit_argus_instance_create.md b/docs/stackit_argus_instance_create.md
deleted file mode 100644
index ed63c92e0..000000000
--- a/docs/stackit_argus_instance_create.md
+++ /dev/null
@@ -1,45 +0,0 @@
-## stackit argus instance create
-
-Creates an Argus instance
-
-### Synopsis
-
-Creates an Argus instance.
-
-```
-stackit argus instance create [flags]
-```
-
-### Examples
-
-```
- Create an Argus instance with name "my-instance" and specify plan by name
- $ stackit argus instance create --name my-instance --plan-name Monitoring-Starter-EU01
-
- Create an Argus instance with name "my-instance" and specify plan by ID
- $ stackit argus instance create --name my-instance --plan-id xxx
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus instance create"
- -n, --name string Instance name
- --plan-id string Plan ID
- --plan-name string Plan name
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances
-
diff --git a/docs/stackit_argus_instance_describe.md b/docs/stackit_argus_instance_describe.md
deleted file mode 100644
index 77d3c6546..000000000
--- a/docs/stackit_argus_instance_describe.md
+++ /dev/null
@@ -1,42 +0,0 @@
-## stackit argus instance describe
-
-Shows details of an Argus instance
-
-### Synopsis
-
-Shows details of an Argus instance.
-
-```
-stackit argus instance describe INSTANCE_ID [flags]
-```
-
-### Examples
-
-```
- Get details of an Argus instance with ID "xxx"
- $ stackit argus instance describe xxx
-
- Get details of an Argus instance with ID "xxx" in JSON format
- $ stackit argus instance describe xxx --output-format json
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus 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
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances
-
diff --git a/docs/stackit_argus_instance_list.md b/docs/stackit_argus_instance_list.md
deleted file mode 100644
index b91308146..000000000
--- a/docs/stackit_argus_instance_list.md
+++ /dev/null
@@ -1,46 +0,0 @@
-## stackit argus instance list
-
-Lists all Argus instances
-
-### Synopsis
-
-Lists all Argus instances.
-
-```
-stackit argus instance list [flags]
-```
-
-### Examples
-
-```
- List all Argus instances
- $ stackit argus instance list
-
- List all Argus instances in JSON format
- $ stackit argus instance list --output-format json
-
- List up to 10 Argus instances
- $ stackit argus instance list --limit 10
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus 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
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances
-
diff --git a/docs/stackit_argus_instance_update.md b/docs/stackit_argus_instance_update.md
deleted file mode 100644
index 25155965e..000000000
--- a/docs/stackit_argus_instance_update.md
+++ /dev/null
@@ -1,48 +0,0 @@
-## stackit argus instance update
-
-Updates an Argus instance
-
-### Synopsis
-
-Updates an Argus instance.
-
-```
-stackit argus instance update INSTANCE_ID [flags]
-```
-
-### Examples
-
-```
- Update the plan of an Argus instance with ID "xxx" by specifying the plan ID
- $ stackit argus instance update xxx --plan-id yyy
-
- Update the plan of an Argus instance with ID "xxx" by specifying the plan name
- $ stackit argus instance update xxx --plan-name Frontend-Starter-EU01
-
- Update the name of an Argus instance with ID "xxx"
- $ stackit argus instance update xxx --name new-instance-name
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus instance update"
- -n, --name string Instance name
- --plan-id string Plan ID
- --plan-name string Plan name
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances
-
diff --git a/docs/stackit_argus_scrape-config.md b/docs/stackit_argus_scrape-config.md
deleted file mode 100644
index 8a96ec535..000000000
--- a/docs/stackit_argus_scrape-config.md
+++ /dev/null
@@ -1,38 +0,0 @@
-## stackit argus scrape-config
-
-Provides functionality for scrape configurations in Argus
-
-### Synopsis
-
-Provides functionality for scrape configurations in Argus.
-
-```
-stackit argus scrape-config [flags]
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus scrape-config"
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus](./stackit_argus.md) - Provides functionality for Argus
-* [stackit argus scrape-config create](./stackit_argus_scrape-config_create.md) - Creates a scrape configuration for an Argus instance
-* [stackit argus scrape-config delete](./stackit_argus_scrape-config_delete.md) - Deletes a scrape configuration from an Argus instance
-* [stackit argus scrape-config describe](./stackit_argus_scrape-config_describe.md) - Shows details of a scrape configuration from an Argus instance
-* [stackit argus scrape-config generate-payload](./stackit_argus_scrape-config_generate-payload.md) - Generates a payload to create/update scrape configurations for an Argus instance
-* [stackit argus scrape-config list](./stackit_argus_scrape-config_list.md) - Lists all scrape configurations of an Argus instance
-* [stackit argus scrape-config update](./stackit_argus_scrape-config_update.md) - Updates a scrape configuration of an Argus instance
-
diff --git a/docs/stackit_argus_scrape-config_create.md b/docs/stackit_argus_scrape-config_create.md
deleted file mode 100644
index 0df2e91bd..000000000
--- a/docs/stackit_argus_scrape-config_create.md
+++ /dev/null
@@ -1,55 +0,0 @@
-## stackit argus scrape-config create
-
-Creates a scrape configuration for an Argus instance
-
-### Synopsis
-
-Creates a scrape configuration job for an Argus instance.
-The payload can be provided as a JSON string or a file path prefixed with "@".
-If no payload is provided, a default payload will be used.
-See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_create for information regarding the payload structure.
-
-```
-stackit argus scrape-config create [flags]
-```
-
-### Examples
-
-```
- Create a scrape configuration on Argus instance "xxx" using default configuration
- $ stackit argus scrape-config create
-
- Create a scrape configuration on Argus instance "xxx" using an API payload sourced from the file "./payload.json"
- $ stackit argus scrape-config create --payload @./payload.json --instance-id xxx
-
- Create a scrape configuration on Argus instance "xxx" using an API payload provided as a JSON string
- $ stackit argus scrape-config create --payload "{...}" --instance-id xxx
-
- Generate a payload with default values, and adapt it with custom values for the different configuration options
- $ stackit argus scrape-config generate-payload > ./payload.json
-
- $ stackit argus scrape-config create --payload @./payload.json --instance-id xxx
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus scrape-config create"
- --instance-id string Instance ID
- --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit argus scrape-config generate-payload")
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus scrape-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus
-
diff --git a/docs/stackit_argus_scrape-config_delete.md b/docs/stackit_argus_scrape-config_delete.md
deleted file mode 100644
index 503433973..000000000
--- a/docs/stackit_argus_scrape-config_delete.md
+++ /dev/null
@@ -1,40 +0,0 @@
-## stackit argus scrape-config delete
-
-Deletes a scrape configuration from an Argus instance
-
-### Synopsis
-
-Deletes a scrape configuration from an Argus instance.
-
-```
-stackit argus scrape-config delete JOB_NAME [flags]
-```
-
-### Examples
-
-```
- Delete a scrape configuration job with name "my-config" from Argus instance "xxx"
- $ stackit argus scrape-config delete my-config --instance-id xxx
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus scrape-config delete"
- --instance-id string Instance ID
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus scrape-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus
-
diff --git a/docs/stackit_argus_scrape-config_describe.md b/docs/stackit_argus_scrape-config_describe.md
deleted file mode 100644
index 565c8f65f..000000000
--- a/docs/stackit_argus_scrape-config_describe.md
+++ /dev/null
@@ -1,43 +0,0 @@
-## stackit argus scrape-config describe
-
-Shows details of a scrape configuration from an Argus instance
-
-### Synopsis
-
-Shows details of a scrape configuration from an Argus instance.
-
-```
-stackit argus scrape-config describe JOB_NAME [flags]
-```
-
-### Examples
-
-```
- Get details of a scrape configuration with name "my-config" from Argus instance "xxx"
- $ stackit argus scrape-config describe my-config --instance-id xxx
-
- Get details of a scrape configuration with name "my-config" from Argus instance "xxx" in JSON format
- $ stackit argus scrape-config describe my-config --output-format json
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus scrape-config describe"
- --instance-id string Instance ID
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus scrape-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus
-
diff --git a/docs/stackit_argus_scrape-config_list.md b/docs/stackit_argus_scrape-config_list.md
deleted file mode 100644
index a4ae284f4..000000000
--- a/docs/stackit_argus_scrape-config_list.md
+++ /dev/null
@@ -1,47 +0,0 @@
-## stackit argus scrape-config list
-
-Lists all scrape configurations of an Argus instance
-
-### Synopsis
-
-Lists all scrape configurations of an Argus instance.
-
-```
-stackit argus scrape-config list [flags]
-```
-
-### Examples
-
-```
- List all scrape configurations of Argus instance "xxx"
- $ stackit argus scrape-config list --instance-id xxx
-
- List all scrape configurations of Argus instance "xxx" in JSON format
- $ stackit argus scrape-config list --instance-id xxx --output-format json
-
- List up to 10 scrape configurations of Argus instance "xxx"
- $ stackit argus scrape-config list --instance-id xxx --limit 10
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus scrape-config list"
- --instance-id string Instance ID
- --limit int Maximum number of entries to list
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus scrape-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus
-
diff --git a/docs/stackit_argus_scrape-config_update.md b/docs/stackit_argus_scrape-config_update.md
deleted file mode 100644
index f335fc673..000000000
--- a/docs/stackit_argus_scrape-config_update.md
+++ /dev/null
@@ -1,51 +0,0 @@
-## stackit argus scrape-config update
-
-Updates a scrape configuration of an Argus instance
-
-### Synopsis
-
-Updates a scrape configuration of an Argus instance.
-The payload can be provided as a JSON string or a file path prefixed with "@".
-See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_update for information regarding the payload structure.
-
-```
-stackit argus scrape-config update JOB_NAME [flags]
-```
-
-### Examples
-
-```
- Update a scrape configuration with name "my-config" from Argus instance "xxx", using an API payload sourced from the file "./payload.json"
- $ stackit argus scrape-config update my-config --payload @./payload.json --instance-id xxx
-
- Update an scrape configuration with name "my-config" from Argus instance "xxx", using an API payload provided as a JSON string
- $ stackit argus scrape-config update my-config --payload "{...}" --instance-id xxx
-
- Generate a payload with the current values of a scrape configuration, and adapt it with custom values for the different configuration options
- $ stackit argus scrape-config generate-payload --job-name my-config > ./payload.json
-
- $ stackit argus scrape-configs update my-config --payload @./payload.json
-```
-
-### Options
-
-```
- -h, --help Help for "stackit argus scrape-config update"
- --instance-id string Instance ID
- --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json
-```
-
-### Options inherited from parent commands
-
-```
- -y, --assume-yes If set, skips all confirmation prompts
- --async If set, runs the command asynchronously
- -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"]
- -p, --project-id string Project ID
- --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
-```
-
-### SEE ALSO
-
-* [stackit argus scrape-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus
-
diff --git a/docs/stackit_auth.md b/docs/stackit_auth.md
index 9cf637bb9..3f9406c46 100644
--- a/docs/stackit_auth.md
+++ b/docs/stackit_auth.md
@@ -23,6 +23,7 @@ stackit auth [flags]
--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")
```
@@ -30,5 +31,7 @@ stackit auth [flags]
* [stackit](./stackit.md) - Manage STACKIT resources using the command line
* [stackit auth activate-service-account](./stackit_auth_activate-service-account.md) - Authenticates using a service account
+* [stackit auth get-access-token](./stackit_auth_get-access-token.md) - Prints a short-lived access token.
* [stackit auth login](./stackit_auth_login.md) - Logs in to the STACKIT CLI
+* [stackit auth logout](./stackit_auth_logout.md) - Logs the user account out of the STACKIT CLI
diff --git a/docs/stackit_auth_activate-service-account.md b/docs/stackit_auth_activate-service-account.md
index 18feefe3d..3d154ebfb 100644
--- a/docs/stackit_auth_activate-service-account.md
+++ b/docs/stackit_auth_activate-service-account.md
@@ -23,17 +23,19 @@ stackit auth activate-service-account [flags]
Activate service account authentication in the STACKIT CLI using the service account token
$ stackit auth activate-service-account --service-account-token my-service-account-token
+
+ Only print the corresponding access token by using the service account token. This access token can be stored as environment variable (STACKIT_ACCESS_TOKEN) in order to be used for all subsequent commands.
+ $ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token
```
### Options
```
-h, --help Help for "stackit auth activate-service-account"
- --jwks-custom-endpoint string Custom endpoint for the jwks API, which is used to get the json web key sets (jwks) to validate tokens when the service-account authentication is activated
+ --only-print-access-token If this is set to true the credentials are not stored in either the keyring or a file
--private-key-path string RSA private key path. It takes precedence over the private key included in the service account key, if present
--service-account-key-path string Service account key path
--service-account-token string Service account long-lived access token
- --token-custom-endpoint string Custom endpoint for the token API, which is used to request access tokens when the service-account authentication is activated
```
### Options inherited from parent commands
@@ -43,6 +45,7 @@ stackit auth activate-service-account [flags]
--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")
```
diff --git a/docs/stackit_auth_get-access-token.md b/docs/stackit_auth_get-access-token.md
new file mode 100644
index 000000000..cc5218002
--- /dev/null
+++ b/docs/stackit_auth_get-access-token.md
@@ -0,0 +1,40 @@
+## stackit auth get-access-token
+
+Prints a short-lived access token.
+
+### Synopsis
+
+Prints a short-lived access token which can be used e.g. for API calls.
+
+```
+stackit auth get-access-token [flags]
+```
+
+### Examples
+
+```
+ Print a short-lived access token
+ $ stackit auth get-access-token
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit auth get-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 auth](./stackit_auth.md) - Authenticates the STACKIT CLI
+
diff --git a/docs/stackit_auth_login.md b/docs/stackit_auth_login.md
index e839b4997..8b08bc947 100644
--- a/docs/stackit_auth_login.md
+++ b/docs/stackit_auth_login.md
@@ -5,6 +5,7 @@ Logs in to the STACKIT CLI
### Synopsis
Logs in to the STACKIT CLI using a user account.
+The authentication is done via a web-based authorization flow, where the command will open a browser window in which you can login to your STACKIT account.
```
stackit auth login [flags]
@@ -30,6 +31,7 @@ stackit auth login [flags]
--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")
```
diff --git a/docs/stackit_auth_logout.md b/docs/stackit_auth_logout.md
new file mode 100644
index 000000000..4361a9925
--- /dev/null
+++ b/docs/stackit_auth_logout.md
@@ -0,0 +1,40 @@
+## stackit auth logout
+
+Logs the user account out of the STACKIT CLI
+
+### Synopsis
+
+Logs the user account out of the STACKIT CLI.
+
+```
+stackit auth logout [flags]
+```
+
+### Examples
+
+```
+ Log out of the STACKIT CLI.
+ $ stackit auth logout
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit auth logout"
+```
+
+### 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 auth](./stackit_auth.md) - Authenticates the STACKIT CLI
+
diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md
index 3fbc2395a..079333c69 100644
--- a/docs/stackit_beta.md
+++ b/docs/stackit_beta.md
@@ -34,11 +34,19 @@ stackit beta [flags]
--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 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 kms](./stackit_beta_kms.md) - Provides functionality for KMS
+* [stackit beta logs](./stackit_beta_logs.md) - Provides functionality for Logs
+* [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_alb.md b/docs/stackit_beta_alb.md
new file mode 100644
index 000000000..a4e8d9866
--- /dev/null
+++ b/docs/stackit_beta_alb.md
@@ -0,0 +1,43 @@
+## stackit beta alb
+
+Manages application loadbalancers
+
+### Synopsis
+
+Manage the lifecycle of application loadbalancers.
+
+```
+stackit beta alb [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb"
+```
+
+### 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 alb create](./stackit_beta_alb_create.md) - Creates an application loadbalancer
+* [stackit beta alb delete](./stackit_beta_alb_delete.md) - Deletes an application loadbalancer
+* [stackit beta alb describe](./stackit_beta_alb_describe.md) - Describes an application loadbalancer
+* [stackit beta alb list](./stackit_beta_alb_list.md) - Lists albs
+* [stackit beta alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials
+* [stackit beta alb plans](./stackit_beta_alb_plans.md) - Lists the application load balancer plans
+* [stackit beta alb pool](./stackit_beta_alb_pool.md) - Manages target pools for application loadbalancers
+* [stackit beta alb quotas](./stackit_beta_alb_quotas.md) - Shows the application load balancer quotas
+* [stackit beta alb template](./stackit_beta_alb_template.md) - creates configuration templates to use for resource creation
+* [stackit beta alb update](./stackit_beta_alb_update.md) - Updates an application loadbalancer
+
diff --git a/docs/stackit_beta_alb_create.md b/docs/stackit_beta_alb_create.md
new file mode 100644
index 000000000..d33b66ab2
--- /dev/null
+++ b/docs/stackit_beta_alb_create.md
@@ -0,0 +1,41 @@
+## stackit beta alb create
+
+Creates an application loadbalancer
+
+### Synopsis
+
+Creates an application loadbalancer.
+
+```
+stackit beta alb create [flags]
+```
+
+### Examples
+
+```
+ Create an application loadbalancer from a configuration file
+ $ stackit beta alb create --configuration my-loadbalancer.json
+```
+
+### Options
+
+```
+ -c, --configuration string Filename of the input configuration file
+ -h, --help Help for "stackit beta alb create"
+```
+
+### 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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+
diff --git a/docs/stackit_beta_alb_delete.md b/docs/stackit_beta_alb_delete.md
new file mode 100644
index 000000000..a83567e86
--- /dev/null
+++ b/docs/stackit_beta_alb_delete.md
@@ -0,0 +1,40 @@
+## stackit beta alb delete
+
+Deletes an application loadbalancer
+
+### Synopsis
+
+Deletes an application loadbalancer.
+
+```
+stackit beta alb delete LOADBALANCER_NAME_ARG [flags]
+```
+
+### Examples
+
+```
+ Delete an application loadbalancer with name "my-load-balancer"
+ $ stackit beta alb delete my-load-balancer
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb 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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+
diff --git a/docs/stackit_beta_alb_describe.md b/docs/stackit_beta_alb_describe.md
new file mode 100644
index 000000000..008ba874c
--- /dev/null
+++ b/docs/stackit_beta_alb_describe.md
@@ -0,0 +1,40 @@
+## stackit beta alb describe
+
+Describes an application loadbalancer
+
+### Synopsis
+
+Describes an application alb.
+
+```
+stackit beta alb describe LOADBALANCER_NAME_ARG [flags]
+```
+
+### Examples
+
+```
+ Get details about an application loadbalancer with name "my-load-balancer"
+ $ stackit beta alb describe my-load-balancer
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb 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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+
diff --git a/docs/stackit_argus_plans.md b/docs/stackit_beta_alb_list.md
similarity index 50%
rename from docs/stackit_argus_plans.md
rename to docs/stackit_beta_alb_list.md
index bc65afb9e..639b541dd 100644
--- a/docs/stackit_argus_plans.md
+++ b/docs/stackit_beta_alb_list.md
@@ -1,33 +1,30 @@
-## stackit argus plans
+## stackit beta alb list
-Lists all Argus service plans
+Lists albs
### Synopsis
-Lists all Argus service plans.
+Lists application load balancers.
```
-stackit argus plans [flags]
+stackit beta alb list [flags]
```
### Examples
```
- List all Argus service plans
- $ stackit argus plans
+ List all load balancers
+ $ stackit beta alb list
- List all Argus service plans in JSON format
- $ stackit argus plans --output-format json
-
- List up to 10 Argus service plans
- $ stackit argus plans --limit 10
+ List the first 10 application load balancers
+ $ stackit beta alb list --limit=10
```
### Options
```
- -h, --help Help for "stackit argus plans"
- --limit int Maximum number of entries to list
+ -h, --help Help for "stackit beta alb list"
+ --limit int Limit the output to the first n elements
```
### Options inherited from parent commands
@@ -37,10 +34,11 @@ stackit argus plans [flags]
--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 argus](./stackit_argus.md) - Provides functionality for Argus
+* [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers
diff --git a/docs/stackit_beta_alb_observability-credentials.md b/docs/stackit_beta_alb_observability-credentials.md
new file mode 100644
index 000000000..f704d001a
--- /dev/null
+++ b/docs/stackit_beta_alb_observability-credentials.md
@@ -0,0 +1,38 @@
+## stackit beta alb observability-credentials
+
+Provides functionality for application loadbalancer credentials
+
+### Synopsis
+
+Provides functionality for application loadbalancer credentials
+
+```
+stackit beta alb observability-credentials [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb observability-credentials"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+* [stackit beta alb observability-credentials add](./stackit_beta_alb_observability-credentials_add.md) - Adds observability credentials to an application load balancer
+* [stackit beta alb observability-credentials delete](./stackit_beta_alb_observability-credentials_delete.md) - Deletes credentials
+* [stackit beta alb observability-credentials describe](./stackit_beta_alb_observability-credentials_describe.md) - Describes observability credentials for the Application Load Balancer
+* [stackit beta alb observability-credentials list](./stackit_beta_alb_observability-credentials_list.md) - Lists all credentials
+* [stackit beta alb observability-credentials update](./stackit_beta_alb_observability-credentials_update.md) - Update credentials
+
diff --git a/docs/stackit_beta_alb_observability-credentials_add.md b/docs/stackit_beta_alb_observability-credentials_add.md
new file mode 100644
index 000000000..9e4e544cc
--- /dev/null
+++ b/docs/stackit_beta_alb_observability-credentials_add.md
@@ -0,0 +1,43 @@
+## stackit beta alb observability-credentials add
+
+Adds observability credentials to an application load balancer
+
+### Synopsis
+
+Adds observability credentials (username and password) to an application load balancer. The credentials can be for Observability or another monitoring tool.
+
+```
+stackit beta alb observability-credentials add [flags]
+```
+
+### Examples
+
+```
+ 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
+```
+
+### Options
+
+```
+ -d, --displayname string Displayname for the credentials
+ -h, --help Help for "stackit beta alb observability-credentials add"
+ --password string Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).
+ -u, --username string Username for the credentials
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials
+
diff --git a/docs/stackit_beta_alb_observability-credentials_delete.md b/docs/stackit_beta_alb_observability-credentials_delete.md
new file mode 100644
index 000000000..8cdbdde27
--- /dev/null
+++ b/docs/stackit_beta_alb_observability-credentials_delete.md
@@ -0,0 +1,40 @@
+## stackit beta alb observability-credentials delete
+
+Deletes credentials
+
+### Synopsis
+
+Deletes credentials.
+
+```
+stackit beta alb observability-credentials delete CREDENTIAL_REF [flags]
+```
+
+### Examples
+
+```
+ Delete credential with name "credential-12345"
+ $ stackit beta alb observability-credentials delete credential-12345
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb observability-credentials 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 alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials
+
diff --git a/docs/stackit_beta_alb_observability-credentials_describe.md b/docs/stackit_beta_alb_observability-credentials_describe.md
new file mode 100644
index 000000000..9eafb8d93
--- /dev/null
+++ b/docs/stackit_beta_alb_observability-credentials_describe.md
@@ -0,0 +1,40 @@
+## stackit beta alb observability-credentials describe
+
+Describes observability credentials for the Application Load Balancer
+
+### Synopsis
+
+Describes observability credentials for the Application Load Balancer.
+
+```
+stackit beta alb observability-credentials describe CREDENTIAL_REF [flags]
+```
+
+### Examples
+
+```
+ Get details about credentials with name "credential-12345"
+ $ stackit beta alb observability-credentials describe credential-12345
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb observability-credentials describe"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials
+
diff --git a/docs/stackit_beta_alb_observability-credentials_list.md b/docs/stackit_beta_alb_observability-credentials_list.md
new file mode 100644
index 000000000..9a71757b2
--- /dev/null
+++ b/docs/stackit_beta_alb_observability-credentials_list.md
@@ -0,0 +1,47 @@
+## stackit beta alb observability-credentials list
+
+Lists all credentials
+
+### Synopsis
+
+Lists all credentials.
+
+```
+stackit beta alb observability-credentials list [flags]
+```
+
+### Examples
+
+```
+ Lists all credentials
+ $ stackit beta alb observability-credentials list
+
+ Lists all credentials in JSON format
+ $ stackit beta alb observability-credentials list --output-format json
+
+ Lists up to 10 credentials
+ $ stackit beta alb observability-credentials list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb observability-credentials list"
+ --limit int Number of credentials 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 alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials
+
diff --git a/docs/stackit_beta_alb_observability-credentials_update.md b/docs/stackit_beta_alb_observability-credentials_update.md
new file mode 100644
index 000000000..61994726d
--- /dev/null
+++ b/docs/stackit_beta_alb_observability-credentials_update.md
@@ -0,0 +1,43 @@
+## stackit beta alb observability-credentials update
+
+Update credentials
+
+### Synopsis
+
+Update credentials.
+
+```
+stackit beta alb observability-credentials update CREDENTIAL_REF_ARG [flags]
+```
+
+### Examples
+
+```
+ Update the password of observability credentials of Application Load Balancer with credentials reference "credentials-xxx", by providing the path to a file with the new password as flag
+ $ stackit beta alb observability-credentials update credentials-xxx --username user1 --displayname user1 --password @./new-password.txt
+```
+
+### Options
+
+```
+ -d, --displayname string Displayname for the credentials
+ -h, --help Help for "stackit beta alb observability-credentials update"
+ --password string Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).
+ -u, --username string Username for the credentials
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 alb observability-credentials](./stackit_beta_alb_observability-credentials.md) - Provides functionality for application loadbalancer credentials
+
diff --git a/docs/stackit_beta_alb_plans.md b/docs/stackit_beta_alb_plans.md
new file mode 100644
index 000000000..3e46e1185
--- /dev/null
+++ b/docs/stackit_beta_alb_plans.md
@@ -0,0 +1,40 @@
+## stackit beta alb plans
+
+Lists the application load balancer plans
+
+### Synopsis
+
+Lists the available application load balancer plans.
+
+```
+stackit beta alb plans [flags]
+```
+
+### Examples
+
+```
+ List all application load balancer plans
+ $ stackit beta alb plans
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb 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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+
diff --git a/docs/stackit_beta_alb_pool.md b/docs/stackit_beta_alb_pool.md
new file mode 100644
index 000000000..1553d06bc
--- /dev/null
+++ b/docs/stackit_beta_alb_pool.md
@@ -0,0 +1,34 @@
+## stackit beta alb pool
+
+Manages target pools for application loadbalancers
+
+### Synopsis
+
+Manage the lifecycle of target pools for application loadbalancers.
+
+```
+stackit beta alb pool [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb 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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+* [stackit beta alb pool update](./stackit_beta_alb_pool_update.md) - Updates an application target pool
+
diff --git a/docs/stackit_beta_alb_pool_update.md b/docs/stackit_beta_alb_pool_update.md
new file mode 100644
index 000000000..156452e11
--- /dev/null
+++ b/docs/stackit_beta_alb_pool_update.md
@@ -0,0 +1,42 @@
+## stackit beta alb pool update
+
+Updates an application target pool
+
+### Synopsis
+
+Updates an application target pool.
+
+```
+stackit beta alb pool update [flags]
+```
+
+### Examples
+
+```
+ 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
+```
+
+### Options
+
+```
+ -c, --configuration string Filename of the input configuration file
+ -h, --help Help for "stackit beta alb pool update"
+ -n, --name string Name of the target pool name to update
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 alb pool](./stackit_beta_alb_pool.md) - Manages target pools for application loadbalancers
+
diff --git a/docs/stackit_beta_alb_quotas.md b/docs/stackit_beta_alb_quotas.md
new file mode 100644
index 000000000..26f9168ce
--- /dev/null
+++ b/docs/stackit_beta_alb_quotas.md
@@ -0,0 +1,40 @@
+## stackit beta alb quotas
+
+Shows the application load balancer quotas
+
+### Synopsis
+
+Shows the application load balancer quotas for the application load balancers.
+
+```
+stackit beta alb quotas [flags]
+```
+
+### Examples
+
+```
+ List all application load balancer quotas
+ $ stackit beta alb quotas
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta alb quotas"
+```
+
+### 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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+
diff --git a/docs/stackit_beta_alb_template.md b/docs/stackit_beta_alb_template.md
new file mode 100644
index 000000000..e914e3024
--- /dev/null
+++ b/docs/stackit_beta_alb_template.md
@@ -0,0 +1,45 @@
+## stackit beta alb template
+
+creates configuration templates to use for resource creation
+
+### Synopsis
+
+creates a json or yaml template file for creating/updating an application loadbalancer or target pool.
+
+```
+stackit beta alb template [flags]
+```
+
+### Examples
+
+```
+ Create a yaml template
+ $ stackit beta alb template --format=yaml --type alb
+
+ Create a json template
+ $ stackit beta alb template --format=json --type pool
+```
+
+### Options
+
+```
+ -f, --format string Defines the output format ('yaml' or 'json'), default is 'json' (default "json")
+ -h, --help Help for "stackit beta alb template"
+ -t, --type string Defines the output type ('alb' or 'pool'), default is 'alb' (default "alb")
+```
+
+### 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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+
diff --git a/docs/stackit_beta_alb_update.md b/docs/stackit_beta_alb_update.md
new file mode 100644
index 000000000..36c4a8dd7
--- /dev/null
+++ b/docs/stackit_beta_alb_update.md
@@ -0,0 +1,41 @@
+## stackit beta alb update
+
+Updates an application loadbalancer
+
+### Synopsis
+
+Updates an application loadbalancer.
+
+```
+stackit beta alb update [flags]
+```
+
+### Examples
+
+```
+ Update an application loadbalancer from a configuration file
+ $ stackit beta alb update --configuration my-loadbalancer.json
+```
+
+### Options
+
+```
+ -c, --configuration string Filename of the input configuration file
+ -h, --help Help for "stackit beta alb update"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 alb](./stackit_beta_alb.md) - Manages application loadbalancers
+
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..f44d3c12d
--- /dev/null
+++ b/docs/stackit_beta_intake.md
@@ -0,0 +1,34 @@
+## 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 runner](./stackit_beta_intake_runner.md) - Provides functionality for Intake Runners
+
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_kms.md b/docs/stackit_beta_kms.md
new file mode 100644
index 000000000..e50cfd05a
--- /dev/null
+++ b/docs/stackit_beta_kms.md
@@ -0,0 +1,37 @@
+## stackit beta kms
+
+Provides functionality for KMS
+
+### Synopsis
+
+Provides functionality for KMS.
+
+```
+stackit beta kms [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta](./stackit_beta.md) - Contains beta STACKIT CLI commands
+* [stackit beta kms key](./stackit_beta_kms_key.md) - Manage KMS keys
+* [stackit beta kms keyring](./stackit_beta_kms_keyring.md) - Manage KMS key rings
+* [stackit beta kms version](./stackit_beta_kms_version.md) - Manage KMS key versions
+* [stackit beta kms wrapping-key](./stackit_beta_kms_wrapping-key.md) - Manage KMS wrapping keys
+
diff --git a/docs/stackit_beta_kms_key.md b/docs/stackit_beta_kms_key.md
new file mode 100644
index 000000000..a22f3d97b
--- /dev/null
+++ b/docs/stackit_beta_kms_key.md
@@ -0,0 +1,40 @@
+## stackit beta kms key
+
+Manage KMS keys
+
+### Synopsis
+
+Provides functionality for key operations inside the KMS
+
+```
+stackit beta kms key [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms](./stackit_beta_kms.md) - Provides functionality for KMS
+* [stackit beta kms key create](./stackit_beta_kms_key_create.md) - Creates a KMS key
+* [stackit beta kms key delete](./stackit_beta_kms_key_delete.md) - Deletes a KMS key
+* [stackit beta kms key describe](./stackit_beta_kms_key_describe.md) - Describe a KMS key
+* [stackit beta kms key import](./stackit_beta_kms_key_import.md) - Import a KMS key
+* [stackit beta kms key list](./stackit_beta_kms_key_list.md) - List all KMS keys
+* [stackit beta kms key restore](./stackit_beta_kms_key_restore.md) - Restore a key
+* [stackit beta kms key rotate](./stackit_beta_kms_key_rotate.md) - Rotate a key
+
diff --git a/docs/stackit_beta_kms_key_create.md b/docs/stackit_beta_kms_key_create.md
new file mode 100644
index 000000000..0c3114a69
--- /dev/null
+++ b/docs/stackit_beta_kms_key_create.md
@@ -0,0 +1,62 @@
+## stackit beta kms key create
+
+Creates a KMS key
+
+### Synopsis
+
+Creates a KMS key.
+
+```
+stackit beta 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 beta 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 beta 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 beta 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 beta 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 beta 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 beta 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 beta 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 beta kms key](./stackit_beta_kms_key.md) - Manage KMS keys
+
diff --git a/docs/stackit_beta_kms_key_delete.md b/docs/stackit_beta_kms_key_delete.md
new file mode 100644
index 000000000..1f67c4ff8
--- /dev/null
+++ b/docs/stackit_beta_kms_key_delete.md
@@ -0,0 +1,41 @@
+## stackit beta kms key delete
+
+Deletes a KMS key
+
+### Synopsis
+
+Deletes a KMS key inside a specific key ring.
+
+```
+stackit beta kms key delete KEY_ID [flags]
+```
+
+### Examples
+
+```
+ Delete a KMS key "MY_KEY_ID" inside the key ring "my-keyring-id"
+ $ stackit beta kms key delete "MY_KEY_ID" --keyring-id "my-keyring-id"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms key](./stackit_beta_kms_key.md) - Manage KMS keys
+
diff --git a/docs/stackit_beta_kms_key_describe.md b/docs/stackit_beta_kms_key_describe.md
new file mode 100644
index 000000000..05e876491
--- /dev/null
+++ b/docs/stackit_beta_kms_key_describe.md
@@ -0,0 +1,41 @@
+## stackit beta kms key describe
+
+Describe a KMS key
+
+### Synopsis
+
+Describe a KMS key
+
+```
+stackit beta kms key describe KEY_ID [flags]
+```
+
+### Examples
+
+```
+ Describe a KMS key with ID xxx of keyring yyy
+ $ stackit beta kms key describe xxx --keyring-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms key](./stackit_beta_kms_key.md) - Manage KMS keys
+
diff --git a/docs/stackit_beta_kms_key_import.md b/docs/stackit_beta_kms_key_import.md
new file mode 100644
index 000000000..efc1ba47a
--- /dev/null
+++ b/docs/stackit_beta_kms_key_import.md
@@ -0,0 +1,46 @@
+## stackit beta 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 beta kms key import KEY_ID [flags]
+```
+
+### Examples
+
+```
+ Import a new version for the given KMS key "MY_KEY_ID" from literal value
+ $ stackit beta 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 beta 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 beta 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 beta kms key](./stackit_beta_kms_key.md) - Manage KMS keys
+
diff --git a/docs/stackit_beta_kms_key_list.md b/docs/stackit_beta_kms_key_list.md
new file mode 100644
index 000000000..766bb0a5d
--- /dev/null
+++ b/docs/stackit_beta_kms_key_list.md
@@ -0,0 +1,44 @@
+## stackit beta kms key list
+
+List all KMS keys
+
+### Synopsis
+
+List all KMS keys inside a key ring.
+
+```
+stackit beta kms key list [flags]
+```
+
+### Examples
+
+```
+ List all KMS keys for the key ring "my-keyring-id"
+ $ stackit beta kms key list --keyring-id "my-keyring-id"
+
+ List all KMS keys in JSON format
+ $ stackit beta kms key list --keyring-id "my-keyring-id" --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms key](./stackit_beta_kms_key.md) - Manage KMS keys
+
diff --git a/docs/stackit_beta_kms_key_restore.md b/docs/stackit_beta_kms_key_restore.md
new file mode 100644
index 000000000..9abd9a85e
--- /dev/null
+++ b/docs/stackit_beta_kms_key_restore.md
@@ -0,0 +1,41 @@
+## stackit beta kms key restore
+
+Restore a key
+
+### Synopsis
+
+Restores the given key from deletion.
+
+```
+stackit beta 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 beta kms key restore "MY_KEY_ID" --keyring-id "my-keyring-id"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms key](./stackit_beta_kms_key.md) - Manage KMS keys
+
diff --git a/docs/stackit_beta_kms_key_rotate.md b/docs/stackit_beta_kms_key_rotate.md
new file mode 100644
index 000000000..7fdbbe3c5
--- /dev/null
+++ b/docs/stackit_beta_kms_key_rotate.md
@@ -0,0 +1,41 @@
+## stackit beta kms key rotate
+
+Rotate a key
+
+### Synopsis
+
+Rotates the given key.
+
+```
+stackit beta 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 beta kms key rotate "MY_KEY_ID" --keyring-id "my-keyring-id"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms key](./stackit_beta_kms_key.md) - Manage KMS keys
+
diff --git a/docs/stackit_beta_kms_keyring.md b/docs/stackit_beta_kms_keyring.md
new file mode 100644
index 000000000..2d87f99d3
--- /dev/null
+++ b/docs/stackit_beta_kms_keyring.md
@@ -0,0 +1,37 @@
+## stackit beta kms keyring
+
+Manage KMS key rings
+
+### Synopsis
+
+Provides functionality for key ring operations inside the KMS
+
+```
+stackit beta kms keyring [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms](./stackit_beta_kms.md) - Provides functionality for KMS
+* [stackit beta kms keyring create](./stackit_beta_kms_keyring_create.md) - Creates a KMS key ring
+* [stackit beta kms keyring delete](./stackit_beta_kms_keyring_delete.md) - Deletes a KMS key ring
+* [stackit beta kms keyring describe](./stackit_beta_kms_keyring_describe.md) - Describe a KMS key ring
+* [stackit beta kms keyring list](./stackit_beta_kms_keyring_list.md) - Lists all KMS key rings
+
diff --git a/docs/stackit_beta_kms_keyring_create.md b/docs/stackit_beta_kms_keyring_create.md
new file mode 100644
index 000000000..d02e6e13e
--- /dev/null
+++ b/docs/stackit_beta_kms_keyring_create.md
@@ -0,0 +1,48 @@
+## stackit beta kms keyring create
+
+Creates a KMS key ring
+
+### Synopsis
+
+Creates a KMS key ring.
+
+```
+stackit beta kms keyring create [flags]
+```
+
+### Examples
+
+```
+ Create a KMS key ring with name "my-keyring"
+ $ stackit beta kms keyring create --name my-keyring
+
+ Create a KMS key ring with a description
+ $ stackit beta kms keyring create --name my-keyring --description my-description
+
+ Create a KMS key ring and print the result as YAML
+ $ stackit beta kms keyring create --name my-keyring -o yaml
+```
+
+### Options
+
+```
+ --description string Optional description of the key ring
+ -h, --help Help for "stackit beta 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 beta kms keyring](./stackit_beta_kms_keyring.md) - Manage KMS key rings
+
diff --git a/docs/stackit_beta_kms_keyring_delete.md b/docs/stackit_beta_kms_keyring_delete.md
new file mode 100644
index 000000000..d5230f353
--- /dev/null
+++ b/docs/stackit_beta_kms_keyring_delete.md
@@ -0,0 +1,40 @@
+## stackit beta kms keyring delete
+
+Deletes a KMS key ring
+
+### Synopsis
+
+Deletes a KMS key ring.
+
+```
+stackit beta kms keyring delete KEYRING-ID [flags]
+```
+
+### Examples
+
+```
+ Delete a KMS key ring with ID "MY_KEYRING_ID"
+ $ stackit beta kms keyring delete "MY_KEYRING_ID"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms keyring](./stackit_beta_kms_keyring.md) - Manage KMS key rings
+
diff --git a/docs/stackit_beta_kms_keyring_describe.md b/docs/stackit_beta_kms_keyring_describe.md
new file mode 100644
index 000000000..9b1381dc0
--- /dev/null
+++ b/docs/stackit_beta_kms_keyring_describe.md
@@ -0,0 +1,40 @@
+## stackit beta kms keyring describe
+
+Describe a KMS key ring
+
+### Synopsis
+
+Describe a KMS key ring
+
+```
+stackit beta kms keyring describe KEYRING_ID [flags]
+```
+
+### Examples
+
+```
+ Describe a KMS key ring with ID xxx
+ $ stackit beta kms keyring describe xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms keyring](./stackit_beta_kms_keyring.md) - Manage KMS key rings
+
diff --git a/docs/stackit_beta_kms_keyring_list.md b/docs/stackit_beta_kms_keyring_list.md
new file mode 100644
index 000000000..c82dae950
--- /dev/null
+++ b/docs/stackit_beta_kms_keyring_list.md
@@ -0,0 +1,43 @@
+## stackit beta kms keyring list
+
+Lists all KMS key rings
+
+### Synopsis
+
+Lists all KMS key rings.
+
+```
+stackit beta kms keyring list [flags]
+```
+
+### Examples
+
+```
+ List all KMS key rings
+ $ stackit beta kms keyring list
+
+ List all KMS key rings in JSON format
+ $ stackit beta kms keyring list --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms keyring](./stackit_beta_kms_keyring.md) - Manage KMS key rings
+
diff --git a/docs/stackit_beta_kms_version.md b/docs/stackit_beta_kms_version.md
new file mode 100644
index 000000000..baf9c5ecb
--- /dev/null
+++ b/docs/stackit_beta_kms_version.md
@@ -0,0 +1,38 @@
+## stackit beta kms version
+
+Manage KMS key versions
+
+### Synopsis
+
+Provides functionality for key version operations inside the KMS
+
+```
+stackit beta kms version [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms](./stackit_beta_kms.md) - Provides functionality for KMS
+* [stackit beta kms version destroy](./stackit_beta_kms_version_destroy.md) - Destroy a key version
+* [stackit beta kms version disable](./stackit_beta_kms_version_disable.md) - Disable a key version
+* [stackit beta kms version enable](./stackit_beta_kms_version_enable.md) - Enable a key version
+* [stackit beta kms version list](./stackit_beta_kms_version_list.md) - List all key versions
+* [stackit beta kms version restore](./stackit_beta_kms_version_restore.md) - Restore a key version
+
diff --git a/docs/stackit_beta_kms_version_destroy.md b/docs/stackit_beta_kms_version_destroy.md
new file mode 100644
index 000000000..8a189ecf2
--- /dev/null
+++ b/docs/stackit_beta_kms_version_destroy.md
@@ -0,0 +1,42 @@
+## stackit beta kms version destroy
+
+Destroy a key version
+
+### Synopsis
+
+Removes the key material of a version.
+
+```
+stackit beta 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 beta kms version destroy 42 --key-id "my-key-id" --keyring-id "my-keyring-id"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms version](./stackit_beta_kms_version.md) - Manage KMS key versions
+
diff --git a/docs/stackit_beta_kms_version_disable.md b/docs/stackit_beta_kms_version_disable.md
new file mode 100644
index 000000000..c2e13a87e
--- /dev/null
+++ b/docs/stackit_beta_kms_version_disable.md
@@ -0,0 +1,42 @@
+## stackit beta kms version disable
+
+Disable a key version
+
+### Synopsis
+
+Disable the given key version.
+
+```
+stackit beta 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 beta kms version disable 42 --key-id "my-key-id" --keyring-id "my-keyring-id"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms version](./stackit_beta_kms_version.md) - Manage KMS key versions
+
diff --git a/docs/stackit_beta_kms_version_enable.md b/docs/stackit_beta_kms_version_enable.md
new file mode 100644
index 000000000..46d23bec0
--- /dev/null
+++ b/docs/stackit_beta_kms_version_enable.md
@@ -0,0 +1,42 @@
+## stackit beta kms version enable
+
+Enable a key version
+
+### Synopsis
+
+Enable the given key version.
+
+```
+stackit beta 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 beta kms version enable 42 --key-id "my-key-id" --keyring-id "my-keyring-id"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms version](./stackit_beta_kms_version.md) - Manage KMS key versions
+
diff --git a/docs/stackit_beta_kms_version_list.md b/docs/stackit_beta_kms_version_list.md
new file mode 100644
index 000000000..bd4a96747
--- /dev/null
+++ b/docs/stackit_beta_kms_version_list.md
@@ -0,0 +1,45 @@
+## stackit beta kms version list
+
+List all key versions
+
+### Synopsis
+
+List all versions of a given key.
+
+```
+stackit beta kms version list [flags]
+```
+
+### Examples
+
+```
+ List all key versions for the key "my-key-id" inside the key ring "my-keyring-id"
+ $ stackit beta kms version list --key-id "my-key-id" --keyring-id "my-keyring-id"
+
+ List all key versions in JSON format
+ $ stackit beta kms version list --key-id "my-key-id" --keyring-id "my-keyring-id" -o json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms version](./stackit_beta_kms_version.md) - Manage KMS key versions
+
diff --git a/docs/stackit_beta_kms_version_restore.md b/docs/stackit_beta_kms_version_restore.md
new file mode 100644
index 000000000..1562d5fa2
--- /dev/null
+++ b/docs/stackit_beta_kms_version_restore.md
@@ -0,0 +1,42 @@
+## stackit beta kms version restore
+
+Restore a key version
+
+### Synopsis
+
+Restores the specified version of a key.
+
+```
+stackit beta 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 beta kms version restore 42 --key-id "my-key-id" --keyring-id "my-keyring-id"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms version](./stackit_beta_kms_version.md) - Manage KMS key versions
+
diff --git a/docs/stackit_beta_kms_wrapping-key.md b/docs/stackit_beta_kms_wrapping-key.md
new file mode 100644
index 000000000..2cef6b863
--- /dev/null
+++ b/docs/stackit_beta_kms_wrapping-key.md
@@ -0,0 +1,37 @@
+## stackit beta kms wrapping-key
+
+Manage KMS wrapping keys
+
+### Synopsis
+
+Provides functionality for wrapping key operations inside the KMS
+
+```
+stackit beta kms wrapping-key [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms](./stackit_beta_kms.md) - Provides functionality for KMS
+* [stackit beta kms wrapping-key create](./stackit_beta_kms_wrapping-key_create.md) - Creates a KMS wrapping key
+* [stackit beta kms wrapping-key delete](./stackit_beta_kms_wrapping-key_delete.md) - Deletes a KMS wrapping key
+* [stackit beta kms wrapping-key describe](./stackit_beta_kms_wrapping-key_describe.md) - Describe a KMS wrapping key
+* [stackit beta kms wrapping-key list](./stackit_beta_kms_wrapping-key_list.md) - Lists all KMS wrapping keys
+
diff --git a/docs/stackit_beta_kms_wrapping-key_create.md b/docs/stackit_beta_kms_wrapping-key_create.md
new file mode 100644
index 000000000..d4087bcbe
--- /dev/null
+++ b/docs/stackit_beta_kms_wrapping-key_create.md
@@ -0,0 +1,49 @@
+## stackit beta kms wrapping-key create
+
+Creates a KMS wrapping key
+
+### Synopsis
+
+Creates a KMS wrapping key.
+
+```
+stackit beta 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 beta 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 beta 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 beta 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 beta kms wrapping-key](./stackit_beta_kms_wrapping-key.md) - Manage KMS wrapping keys
+
diff --git a/docs/stackit_beta_kms_wrapping-key_delete.md b/docs/stackit_beta_kms_wrapping-key_delete.md
new file mode 100644
index 000000000..0dfd43a03
--- /dev/null
+++ b/docs/stackit_beta_kms_wrapping-key_delete.md
@@ -0,0 +1,41 @@
+## stackit beta kms wrapping-key delete
+
+Deletes a KMS wrapping key
+
+### Synopsis
+
+Deletes a KMS wrapping key inside a specific key ring.
+
+```
+stackit beta 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 beta kms wrapping-key delete "MY_WRAPPING_KEY_ID" --keyring-id "my-keyring-id"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms wrapping-key](./stackit_beta_kms_wrapping-key.md) - Manage KMS wrapping keys
+
diff --git a/docs/stackit_beta_kms_wrapping-key_describe.md b/docs/stackit_beta_kms_wrapping-key_describe.md
new file mode 100644
index 000000000..6e82cd595
--- /dev/null
+++ b/docs/stackit_beta_kms_wrapping-key_describe.md
@@ -0,0 +1,41 @@
+## stackit beta kms wrapping-key describe
+
+Describe a KMS wrapping key
+
+### Synopsis
+
+Describe a KMS wrapping key
+
+```
+stackit beta kms wrapping-key describe WRAPPING_KEY_ID [flags]
+```
+
+### Examples
+
+```
+ Describe a KMS wrapping key with ID xxx of keyring yyy
+ $ stackit beta kms wrappingkey describe xxx --keyring-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms wrapping-key](./stackit_beta_kms_wrapping-key.md) - Manage KMS wrapping keys
+
diff --git a/docs/stackit_beta_kms_wrapping-key_list.md b/docs/stackit_beta_kms_wrapping-key_list.md
new file mode 100644
index 000000000..f17c23212
--- /dev/null
+++ b/docs/stackit_beta_kms_wrapping-key_list.md
@@ -0,0 +1,44 @@
+## stackit beta kms wrapping-key list
+
+Lists all KMS wrapping keys
+
+### Synopsis
+
+Lists all KMS wrapping keys inside a key ring.
+
+```
+stackit beta kms wrapping-key list [flags]
+```
+
+### Examples
+
+```
+ List all KMS wrapping keys for the key ring "my-keyring-id"
+ $ stackit beta kms wrapping-key list --keyring-id "my-keyring-id"
+
+ List all KMS wrapping keys in JSON format
+ $ stackit beta kms wrapping-key list --keyring-id "my-keyring-id" --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta kms wrapping-key](./stackit_beta_kms_wrapping-key.md) - Manage KMS wrapping keys
+
diff --git a/docs/stackit_beta_logs.md b/docs/stackit_beta_logs.md
new file mode 100644
index 000000000..91998e3c1
--- /dev/null
+++ b/docs/stackit_beta_logs.md
@@ -0,0 +1,34 @@
+## stackit beta logs
+
+Provides functionality for Logs
+
+### Synopsis
+
+Provides functionality for Logs.
+
+```
+stackit beta logs [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta](./stackit_beta.md) - Contains beta STACKIT CLI commands
+* [stackit beta logs instance](./stackit_beta_logs_instance.md) - Provides functionality for Logs instances
+
diff --git a/docs/stackit_beta_logs_instance.md b/docs/stackit_beta_logs_instance.md
new file mode 100644
index 000000000..85831068e
--- /dev/null
+++ b/docs/stackit_beta_logs_instance.md
@@ -0,0 +1,38 @@
+## stackit beta logs instance
+
+Provides functionality for Logs instances
+
+### Synopsis
+
+Provides functionality for Logs instances.
+
+```
+stackit beta logs instance [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta logs](./stackit_beta_logs.md) - Provides functionality for Logs
+* [stackit beta logs instance create](./stackit_beta_logs_instance_create.md) - Creates a Logs instance
+* [stackit beta logs instance delete](./stackit_beta_logs_instance_delete.md) - Deletes the given Logs instance
+* [stackit beta logs instance describe](./stackit_beta_logs_instance_describe.md) - Shows details of a Logs instance
+* [stackit beta logs instance list](./stackit_beta_logs_instance_list.md) - Lists Logs instances
+* [stackit beta logs instance update](./stackit_beta_logs_instance_update.md) - Updates a Logs instance
+
diff --git a/docs/stackit_beta_logs_instance_create.md b/docs/stackit_beta_logs_instance_create.md
new file mode 100644
index 000000000..3d00e6cea
--- /dev/null
+++ b/docs/stackit_beta_logs_instance_create.md
@@ -0,0 +1,50 @@
+## stackit beta logs instance create
+
+Creates a Logs instance
+
+### Synopsis
+
+Creates a Logs instance.
+
+```
+stackit beta logs instance create [flags]
+```
+
+### Examples
+
+```
+ Create a Logs instance with name "my-instance" and retention time 10 days
+ $ stackit beta 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 beta 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 beta 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 beta 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 beta logs instance](./stackit_beta_logs_instance.md) - Provides functionality for Logs instances
+
diff --git a/docs/stackit_beta_logs_instance_delete.md b/docs/stackit_beta_logs_instance_delete.md
new file mode 100644
index 000000000..a64cedb3e
--- /dev/null
+++ b/docs/stackit_beta_logs_instance_delete.md
@@ -0,0 +1,40 @@
+## stackit beta logs instance delete
+
+Deletes the given Logs instance
+
+### Synopsis
+
+Deletes the given Logs instance.
+
+```
+stackit beta logs instance delete INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Delete a Logs instance with ID "xxx"
+ $ stackit beta logs instance delete "xxx"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta logs instance](./stackit_beta_logs_instance.md) - Provides functionality for Logs instances
+
diff --git a/docs/stackit_beta_logs_instance_describe.md b/docs/stackit_beta_logs_instance_describe.md
new file mode 100644
index 000000000..18218a879
--- /dev/null
+++ b/docs/stackit_beta_logs_instance_describe.md
@@ -0,0 +1,43 @@
+## stackit beta logs instance describe
+
+Shows details of a Logs instance
+
+### Synopsis
+
+Shows details of a Logs instance
+
+```
+stackit beta logs instance describe INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Get details of a Logs instance with ID "xxx"
+ $ stackit beta logs instance describe xxx
+
+ Get details of a Logs instance with ID "xxx" in JSON format
+ $ stackit beta logs instance describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta logs instance](./stackit_beta_logs_instance.md) - Provides functionality for Logs instances
+
diff --git a/docs/stackit_beta_logs_instance_list.md b/docs/stackit_beta_logs_instance_list.md
new file mode 100644
index 000000000..6e53961ee
--- /dev/null
+++ b/docs/stackit_beta_logs_instance_list.md
@@ -0,0 +1,44 @@
+## stackit beta logs instance list
+
+Lists Logs instances
+
+### Synopsis
+
+Lists Logs instances within the project.
+
+```
+stackit beta logs instance list [flags]
+```
+
+### Examples
+
+```
+ List all Logs instances
+ $ stackit beta logs instance list
+
+ List the first 10 Logs instances
+ $ stackit beta logs instance list --limit=10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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 beta logs instance](./stackit_beta_logs_instance.md) - Provides functionality for Logs instances
+
diff --git a/docs/stackit_beta_logs_instance_update.md b/docs/stackit_beta_logs_instance_update.md
new file mode 100644
index 000000000..da546c4cf
--- /dev/null
+++ b/docs/stackit_beta_logs_instance_update.md
@@ -0,0 +1,50 @@
+## stackit beta logs instance update
+
+Updates a Logs instance
+
+### Synopsis
+
+Updates a Logs instance.
+
+```
+stackit beta logs instance update INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Update the display name of the Logs instance with ID "xxx"
+ $ stackit beta logs instance update xxx --display-name new-name
+
+ Update the retention time of the Logs instance with ID "xxx"
+ $ stackit beta logs instance update xxx --retention-days 40
+
+ Update the ACL of the Logs instance with ID "xxx"
+ $ stackit beta 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 beta 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 beta logs instance](./stackit_beta_logs_instance.md) - Provides functionality for Logs instances
+
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.md b/docs/stackit_beta_sqlserverflex.md
index a832ef36e..1e630a067 100644
--- a/docs/stackit_beta_sqlserverflex.md
+++ b/docs/stackit_beta_sqlserverflex.md
@@ -23,6 +23,7 @@ stackit beta sqlserverflex [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_database.md b/docs/stackit_beta_sqlserverflex_database.md
index 0ff6409a5..19a1d9052 100644
--- a/docs/stackit_beta_sqlserverflex_database.md
+++ b/docs/stackit_beta_sqlserverflex_database.md
@@ -23,6 +23,7 @@ stackit beta sqlserverflex database [flags]
--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")
```
@@ -31,4 +32,6 @@ stackit beta sqlserverflex database [flags]
* [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex
* [stackit beta sqlserverflex database create](./stackit_beta_sqlserverflex_database_create.md) - Creates a SQLServer Flex database
* [stackit beta sqlserverflex database delete](./stackit_beta_sqlserverflex_database_delete.md) - Deletes a SQLServer Flex database
+* [stackit beta sqlserverflex database describe](./stackit_beta_sqlserverflex_database_describe.md) - Shows details of an SQLServer Flex database
+* [stackit beta sqlserverflex database list](./stackit_beta_sqlserverflex_database_list.md) - Lists all SQLServer Flex databases
diff --git a/docs/stackit_beta_sqlserverflex_database_create.md b/docs/stackit_beta_sqlserverflex_database_create.md
index 4f4fdab41..a34712040 100644
--- a/docs/stackit_beta_sqlserverflex_database_create.md
+++ b/docs/stackit_beta_sqlserverflex_database_create.md
@@ -33,6 +33,7 @@ stackit beta sqlserverflex database create DATABASE_NAME [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_database_delete.md b/docs/stackit_beta_sqlserverflex_database_delete.md
index b2c6bc471..ebd5ab0a1 100644
--- a/docs/stackit_beta_sqlserverflex_database_delete.md
+++ b/docs/stackit_beta_sqlserverflex_database_delete.md
@@ -32,6 +32,7 @@ stackit beta sqlserverflex database delete DATABASE_NAME [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_database_describe.md b/docs/stackit_beta_sqlserverflex_database_describe.md
new file mode 100644
index 000000000..290284e23
--- /dev/null
+++ b/docs/stackit_beta_sqlserverflex_database_describe.md
@@ -0,0 +1,44 @@
+## stackit beta sqlserverflex database describe
+
+Shows details of an SQLServer Flex database
+
+### Synopsis
+
+Shows details of an SQLServer Flex database.
+
+```
+stackit beta sqlserverflex database describe DATABASE_NAME [flags]
+```
+
+### Examples
+
+```
+ Get details of an SQLServer Flex database with name "my-database" of instance with ID "xxx"
+ $ stackit beta sqlserverflex database describe my-database --instance-id xxx
+
+ Get details of an SQLServer Flex database with name "my-database" of instance with ID "xxx" in JSON format
+ $ stackit beta sqlserverflex database describe my-database --instance-id xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta sqlserverflex database describe"
+ --instance-id string SQLServer Flex instance ID
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 sqlserverflex database](./stackit_beta_sqlserverflex_database.md) - Provides functionality for SQLServer Flex databases
+
diff --git a/docs/stackit_beta_sqlserverflex_database_list.md b/docs/stackit_beta_sqlserverflex_database_list.md
new file mode 100644
index 000000000..73797c240
--- /dev/null
+++ b/docs/stackit_beta_sqlserverflex_database_list.md
@@ -0,0 +1,48 @@
+## stackit beta sqlserverflex database list
+
+Lists all SQLServer Flex databases
+
+### Synopsis
+
+Lists all SQLServer Flex databases.
+
+```
+stackit beta sqlserverflex database list [flags]
+```
+
+### Examples
+
+```
+ List all SQLServer Flex databases of instance with ID "xxx"
+ $ stackit beta sqlserverflex database list --instance-id xxx
+
+ List all SQLServer Flex databases of instance with ID "xxx" in JSON format
+ $ stackit beta sqlserverflex database list --instance-id xxx --output-format json
+
+ List up to 10 SQLServer Flex databases of instance with ID "xxx"
+ $ stackit beta sqlserverflex database list --instance-id xxx --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta sqlserverflex database list"
+ --instance-id string SQLServer Flex instance ID
+ --limit int Maximum number of entries to list
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 sqlserverflex database](./stackit_beta_sqlserverflex_database.md) - Provides functionality for SQLServer Flex databases
+
diff --git a/docs/stackit_beta_sqlserverflex_instance.md b/docs/stackit_beta_sqlserverflex_instance.md
index 6e6680392..5ab816add 100644
--- a/docs/stackit_beta_sqlserverflex_instance.md
+++ b/docs/stackit_beta_sqlserverflex_instance.md
@@ -23,6 +23,7 @@ stackit beta sqlserverflex instance [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_instance_create.md b/docs/stackit_beta_sqlserverflex_instance_create.md
index 2fc42b52c..b297bf7b0 100644
--- a/docs/stackit_beta_sqlserverflex_instance_create.md
+++ b/docs/stackit_beta_sqlserverflex_instance_create.md
@@ -16,11 +16,12 @@ stackit beta sqlserverflex instance create [flags]
Create a SQLServer Flex instance with name "my-instance" and specify flavor by CPU and RAM. Other parameters are set to default values
$ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4
- Create a SQLServer Flex instance with name "my-instance" and specify flavor by ID. Other parameters are set to default values
+ Create a SQLServer Flex instance with name "my-instance" and specify flavor by ID. Other parameters are set to default values.
+ The flavor ID can be retrieved by running "$ stackit beta sqlserverflex options --flavors"
$ 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
@@ -47,6 +48,7 @@ stackit beta sqlserverflex instance create [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_instance_delete.md b/docs/stackit_beta_sqlserverflex_instance_delete.md
index 376b12278..548bfb952 100644
--- a/docs/stackit_beta_sqlserverflex_instance_delete.md
+++ b/docs/stackit_beta_sqlserverflex_instance_delete.md
@@ -30,6 +30,7 @@ stackit beta sqlserverflex instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_instance_describe.md b/docs/stackit_beta_sqlserverflex_instance_describe.md
index cb761789a..42e6a2e3d 100644
--- a/docs/stackit_beta_sqlserverflex_instance_describe.md
+++ b/docs/stackit_beta_sqlserverflex_instance_describe.md
@@ -33,6 +33,7 @@ stackit beta sqlserverflex instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_instance_list.md b/docs/stackit_beta_sqlserverflex_instance_list.md
index 716547fed..133a1836d 100644
--- a/docs/stackit_beta_sqlserverflex_instance_list.md
+++ b/docs/stackit_beta_sqlserverflex_instance_list.md
@@ -37,6 +37,7 @@ stackit beta sqlserverflex instance list [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_instance_update.md b/docs/stackit_beta_sqlserverflex_instance_update.md
index 93b3379b8..740628789 100644
--- a/docs/stackit_beta_sqlserverflex_instance_update.md
+++ b/docs/stackit_beta_sqlserverflex_instance_update.md
@@ -40,6 +40,7 @@ stackit beta sqlserverflex instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_options.md b/docs/stackit_beta_sqlserverflex_options.md
index dd4a7063b..b7035249e 100644
--- a/docs/stackit_beta_sqlserverflex_options.md
+++ b/docs/stackit_beta_sqlserverflex_options.md
@@ -48,6 +48,7 @@ stackit beta sqlserverflex options [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_user.md b/docs/stackit_beta_sqlserverflex_user.md
index afdf81727..b922dba2a 100644
--- a/docs/stackit_beta_sqlserverflex_user.md
+++ b/docs/stackit_beta_sqlserverflex_user.md
@@ -23,6 +23,7 @@ stackit beta sqlserverflex user [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_user_create.md b/docs/stackit_beta_sqlserverflex_user_create.md
index 910a469ab..47aa6a5e3 100644
--- a/docs/stackit_beta_sqlserverflex_user_create.md
+++ b/docs/stackit_beta_sqlserverflex_user_create.md
@@ -5,10 +5,14 @@ Creates a SQLServer Flex user
### Synopsis
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
```
stackit beta sqlserverflex user create [flags]
@@ -18,10 +22,10 @@ stackit beta sqlserverflex user create [flags]
```
Create a SQLServer Flex user for instance with ID "xxx" and specify the username, role and database
- $ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles my-role --database my-database
+ $ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "##STACKIT_DatabaseManager##"
Create a SQLServer Flex user for instance with ID "xxx", specifying multiple roles
- $ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "my-role-1,my-role-2"
+ $ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "##STACKIT_LoginManager##,##STACKIT_DatabaseManager##"
```
### Options
@@ -40,6 +44,7 @@ stackit beta sqlserverflex user create [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_user_delete.md b/docs/stackit_beta_sqlserverflex_user_delete.md
index ff3d7d647..9ec1d1581 100644
--- a/docs/stackit_beta_sqlserverflex_user_delete.md
+++ b/docs/stackit_beta_sqlserverflex_user_delete.md
@@ -32,6 +32,7 @@ stackit beta sqlserverflex user delete USER_ID [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_user_describe.md b/docs/stackit_beta_sqlserverflex_user_describe.md
index 802160cff..b4499f2fa 100644
--- a/docs/stackit_beta_sqlserverflex_user_describe.md
+++ b/docs/stackit_beta_sqlserverflex_user_describe.md
@@ -36,6 +36,7 @@ stackit beta sqlserverflex user describe USER_ID [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_user_list.md b/docs/stackit_beta_sqlserverflex_user_list.md
index a640a144f..5a7b79d61 100644
--- a/docs/stackit_beta_sqlserverflex_user_list.md
+++ b/docs/stackit_beta_sqlserverflex_user_list.md
@@ -38,6 +38,7 @@ stackit beta sqlserverflex user list [flags]
--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")
```
diff --git a/docs/stackit_beta_sqlserverflex_user_reset-password.md b/docs/stackit_beta_sqlserverflex_user_reset-password.md
index 25085596b..210e885bc 100644
--- a/docs/stackit_beta_sqlserverflex_user_reset-password.md
+++ b/docs/stackit_beta_sqlserverflex_user_reset-password.md
@@ -32,6 +32,7 @@ stackit beta sqlserverflex user reset-password USER_ID [flags]
--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")
```
diff --git a/docs/stackit_config.md b/docs/stackit_config.md
index 7a5d45296..664b5dcd7 100644
--- a/docs/stackit_config.md
+++ b/docs/stackit_config.md
@@ -28,6 +28,7 @@ stackit config [flags]
--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")
```
diff --git a/docs/stackit_config_list.md b/docs/stackit_config_list.md
index f8b68be6e..6d08e1888 100644
--- a/docs/stackit_config_list.md
+++ b/docs/stackit_config_list.md
@@ -39,6 +39,7 @@ stackit config list [flags]
--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")
```
diff --git a/docs/stackit_config_profile.md b/docs/stackit_config_profile.md
index 947f3cc9c..6407d11db 100644
--- a/docs/stackit_config_profile.md
+++ b/docs/stackit_config_profile.md
@@ -26,6 +26,7 @@ stackit config profile [flags]
--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")
```
@@ -34,6 +35,8 @@ stackit config profile [flags]
* [stackit config](./stackit_config.md) - Provides functionality for CLI configuration options
* [stackit config profile create](./stackit_config_profile_create.md) - Creates a CLI configuration profile
* [stackit config profile delete](./stackit_config_profile_delete.md) - Delete a CLI configuration profile
+* [stackit config profile export](./stackit_config_profile_export.md) - Exports a CLI configuration profile
+* [stackit config profile import](./stackit_config_profile_import.md) - Imports a CLI configuration profile
* [stackit config profile list](./stackit_config_profile_list.md) - Lists all CLI configuration profiles
* [stackit config profile set](./stackit_config_profile_set.md) - Set a CLI configuration profile
* [stackit config profile unset](./stackit_config_profile_unset.md) - Unset the current active CLI configuration profile
diff --git a/docs/stackit_config_profile_create.md b/docs/stackit_config_profile_create.md
index 5f9a45af3..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
@@ -39,6 +41,7 @@ stackit config profile create PROFILE [flags]
--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")
```
diff --git a/docs/stackit_config_profile_delete.md b/docs/stackit_config_profile_delete.md
index 4de5f7545..770e3563e 100644
--- a/docs/stackit_config_profile_delete.md
+++ b/docs/stackit_config_profile_delete.md
@@ -31,6 +31,7 @@ stackit config profile delete PROFILE [flags]
--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")
```
diff --git a/docs/stackit_config_profile_export.md b/docs/stackit_config_profile_export.md
new file mode 100644
index 000000000..906a20eac
--- /dev/null
+++ b/docs/stackit_config_profile_export.md
@@ -0,0 +1,44 @@
+## stackit config profile export
+
+Exports a CLI configuration profile
+
+### Synopsis
+
+Exports a CLI configuration profile.
+
+```
+stackit config profile export PROFILE_NAME [flags]
+```
+
+### Examples
+
+```
+ Export a profile with name "PROFILE_NAME" to a file in your current directory
+ $ stackit config profile export PROFILE_NAME
+
+ Export a profile with name "PROFILE_NAME"" to a specific file path FILE_PATH
+ $ stackit config profile export PROFILE_NAME --file-path FILE_PATH
+```
+
+### Options
+
+```
+ -f, --file-path string If set, writes the config to the given file path. If unset, writes the config to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json'
+ -h, --help Help for "stackit config profile export"
+```
+
+### 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 config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles
+
diff --git a/docs/stackit_config_profile_import.md b/docs/stackit_config_profile_import.md
new file mode 100644
index 000000000..86f253fc8
--- /dev/null
+++ b/docs/stackit_config_profile_import.md
@@ -0,0 +1,46 @@
+## stackit config profile import
+
+Imports a CLI configuration profile
+
+### Synopsis
+
+Imports a CLI configuration profile.
+
+```
+stackit config profile import [flags]
+```
+
+### Examples
+
+```
+ Import a config with name "PROFILE_NAME" from file "./config.json"
+ $ stackit config profile import --name PROFILE_NAME --config `@./config.json`
+
+ Import a config with name "PROFILE_NAME" from file "./config.json" and do not set as active
+ $ stackit config profile import --name PROFILE_NAME --config `@./config.json` --no-set
+```
+
+### Options
+
+```
+ -c, --config string File where configuration will be imported from
+ -h, --help Help for "stackit config profile import"
+ --name string Profile name
+ --no-set Set the imported profile not as active
+```
+
+### 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 config profile](./stackit_config_profile.md) - Manage the CLI configuration profiles
+
diff --git a/docs/stackit_config_profile_list.md b/docs/stackit_config_profile_list.md
index 9a8985341..ba39080d0 100644
--- a/docs/stackit_config_profile_list.md
+++ b/docs/stackit_config_profile_list.md
@@ -33,6 +33,7 @@ stackit config profile list [flags]
--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")
```
diff --git a/docs/stackit_config_profile_set.md b/docs/stackit_config_profile_set.md
index a39604ff1..64f6126b2 100644
--- a/docs/stackit_config_profile_set.md
+++ b/docs/stackit_config_profile_set.md
@@ -33,6 +33,7 @@ stackit config profile set PROFILE [flags]
--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")
```
diff --git a/docs/stackit_config_profile_unset.md b/docs/stackit_config_profile_unset.md
index 410469005..5895391b0 100644
--- a/docs/stackit_config_profile_unset.md
+++ b/docs/stackit_config_profile_unset.md
@@ -31,6 +31,7 @@ stackit config profile unset [flags]
--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")
```
diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md
index 11c7861b7..c1f104958 100644
--- a/docs/stackit_config_set.md
+++ b/docs/stackit_config_set.md
@@ -29,25 +29,40 @@ stackit config set [flags]
### Options
```
- --argus-custom-endpoint string Argus API base URL, used in calls to this API
- --authorization-custom-endpoint string Authorization API base URL, used in calls to this API
- --dns-custom-endpoint string DNS API base URL, used in calls to this API
- -h, --help Help for "stackit config set"
- --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
- --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
- --opensearch-custom-endpoint string OpenSearch API base URL, used in calls to this API
- --postgresflex-custom-endpoint string PostgreSQL Flex API base URL, used in calls to this API
- --rabbitmq-custom-endpoint string RabbitMQ API base URL, used in calls to this API
- --redis-custom-endpoint string Redis API base URL, used in calls to this API
- --resource-manager-custom-endpoint string Resource Manager API base URL, used in calls to this API
- --secrets-manager-custom-endpoint string Secrets Manager 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
- --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)
- --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
+ --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
+ --observability-custom-endpoint string Observability API base URL, used in calls to this API
+ --opensearch-custom-endpoint string OpenSearch API base URL, used in calls to this API
+ --postgresflex-custom-endpoint string PostgreSQL Flex API base URL, used in calls to this API
+ --rabbitmq-custom-endpoint string RabbitMQ API base URL, used in calls to this API
+ --redis-custom-endpoint string Redis API base URL, used in calls to this API
+ --resource-manager-custom-endpoint string Resource Manager API base URL, used in calls to this API
+ --runcommand-custom-endpoint string Run Command API base URL, used in calls to this API
+ --secrets-manager-custom-endpoint string Secrets Manager API base URL, used in calls to this API
+ --server-osupdate-custom-endpoint string Server Update Management API base URL, used in calls to this API
+ --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
+ --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.
```
### Options inherited from parent commands
@@ -57,6 +72,7 @@ stackit config set [flags]
--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")
```
diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md
index b07b392a1..4f0f7f229 100644
--- a/docs/stackit_config_unset.md
+++ b/docs/stackit_config_unset.md
@@ -26,29 +26,45 @@ stackit config unset [flags]
### Options
```
- --argus-custom-endpoint Argus API base URL. If unset, uses the default base URL
- --async Configuration option to run commands asynchronously
- --authorization-custom-endpoint Authorization API base URL. If unset, uses the default base URL
- --dns-custom-endpoint DNS API base URL. If unset, uses the default base URL
- -h, --help Help for "stackit config unset"
- --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
- --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
- --opensearch-custom-endpoint OpenSearch API base URL. If unset, uses the default base URL
- --output-format Output format
- --postgresflex-custom-endpoint PostgreSQL Flex API base URL. If unset, uses the default base URL
- --project-id Project ID
- --rabbitmq-custom-endpoint RabbitMQ API base URL. If unset, uses the default base URL
- --redis-custom-endpoint Redis API base URL. If unset, uses the default base URL
- --resource-manager-custom-endpoint Resource Manager API base URL. If unset, uses the default base URL
- --secrets-manager-custom-endpoint Secrets Manager API base URL. If unset, uses the default base URL
- --service-account-custom-endpoint SKE 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
- --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
- --verbosity Verbosity of the CLI
+ --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
+ --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
+ --observability-custom-endpoint Observability API base URL. If unset, uses the default base URL
+ --opensearch-custom-endpoint OpenSearch API base URL. If unset, uses the default base URL
+ --output-format Output format
+ --postgresflex-custom-endpoint PostgreSQL Flex API base URL. If unset, uses the default base URL
+ --project-id Project ID
+ --rabbitmq-custom-endpoint RabbitMQ API base URL. If unset, uses the default base URL
+ --redis-custom-endpoint Redis API base URL. If unset, uses the default base URL
+ --region Region
+ --resource-manager-custom-endpoint Resource Manager API base URL. If unset, uses the default base URL
+ --runcommand-custom-endpoint Server Command base URL. If unset, uses the default base URL
+ --secrets-manager-custom-endpoint Secrets Manager API base URL. If unset, uses the default base URL
+ --server-osupdate-custom-endpoint Server Update Management base URL. If unset, uses the default base URL
+ --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 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
diff --git a/docs/stackit_curl.md b/docs/stackit_curl.md
index 27938ca6d..ef387edb5 100644
--- a/docs/stackit_curl.md
+++ b/docs/stackit_curl.md
@@ -17,7 +17,7 @@ stackit curl URL [flags]
$ stackit curl https://dns.api.stackit.cloud/v1/projects/xxx/zones
Get all the DNS zones for project with ID xxx via GET request to https://dns.api.stackit.cloud/v1/projects/xxx/zones, write complete response (headers and body) to file "./output.txt"
- $ stackit curl https://dns.api.stackit.cloud/v1/projects/xxx/zones -include --output ./output.txt
+ $ stackit curl https://dns.api.stackit.cloud/v1/projects/xxx/zones --include --output ./output.txt
Create a new DNS zone for project with ID xxx via POST request to https://dns.api.stackit.cloud/v1/projects/xxx/zones with payload from file "./payload.json"
$ stackit curl https://dns.api.stackit.cloud/v1/projects/xxx/zones -X POST --data @./payload.json
@@ -45,6 +45,7 @@ stackit curl URL [flags]
--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")
```
diff --git a/docs/stackit_dns.md b/docs/stackit_dns.md
index 07d1fa87c..a3e7279ba 100644
--- a/docs/stackit_dns.md
+++ b/docs/stackit_dns.md
@@ -23,6 +23,7 @@ stackit dns [flags]
--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")
```
diff --git a/docs/stackit_dns_record-set.md b/docs/stackit_dns_record-set.md
index 241199be6..8796d62f2 100644
--- a/docs/stackit_dns_record-set.md
+++ b/docs/stackit_dns_record-set.md
@@ -23,6 +23,7 @@ stackit dns record-set [flags]
--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")
```
diff --git a/docs/stackit_dns_record-set_create.md b/docs/stackit_dns_record-set_create.md
index 0d8cb5a53..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
```
@@ -36,6 +36,7 @@ stackit dns record-set create [flags]
--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")
```
diff --git a/docs/stackit_dns_record-set_delete.md b/docs/stackit_dns_record-set_delete.md
index c831ba208..487c5a621 100644
--- a/docs/stackit_dns_record-set_delete.md
+++ b/docs/stackit_dns_record-set_delete.md
@@ -31,6 +31,7 @@ stackit dns record-set delete RECORD_SET_ID [flags]
--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")
```
diff --git a/docs/stackit_dns_record-set_describe.md b/docs/stackit_dns_record-set_describe.md
index a29428708..fb9ab8873 100644
--- a/docs/stackit_dns_record-set_describe.md
+++ b/docs/stackit_dns_record-set_describe.md
@@ -34,6 +34,7 @@ stackit dns record-set describe RECORD_SET_ID [flags]
--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")
```
diff --git a/docs/stackit_dns_record-set_list.md b/docs/stackit_dns_record-set_list.md
index 738c77f14..75cc555a2 100644
--- a/docs/stackit_dns_record-set_list.md
+++ b/docs/stackit_dns_record-set_list.md
@@ -50,6 +50,7 @@ stackit dns record-set list [flags]
--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")
```
diff --git a/docs/stackit_dns_record-set_update.md b/docs/stackit_dns_record-set_update.md
index ff150283f..9d369c4f6 100644
--- a/docs/stackit_dns_record-set_update.md
+++ b/docs/stackit_dns_record-set_update.md
@@ -35,6 +35,7 @@ stackit dns record-set update RECORD_SET_ID [flags]
--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")
```
diff --git a/docs/stackit_dns_zone.md b/docs/stackit_dns_zone.md
index cd1bef180..a5e705ded 100644
--- a/docs/stackit_dns_zone.md
+++ b/docs/stackit_dns_zone.md
@@ -23,12 +23,14 @@ stackit dns zone [flags]
--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 dns](./stackit_dns.md) - Provides functionality for DNS
+* [stackit dns zone clone](./stackit_dns_zone_clone.md) - Clones a DNS zone
* [stackit dns zone create](./stackit_dns_zone_create.md) - Creates a DNS zone
* [stackit dns zone delete](./stackit_dns_zone_delete.md) - Deletes a DNS zone
* [stackit dns zone describe](./stackit_dns_zone_describe.md) - Shows details of a DNS zone
diff --git a/docs/stackit_dns_zone_clone.md b/docs/stackit_dns_zone_clone.md
new file mode 100644
index 000000000..2b944f077
--- /dev/null
+++ b/docs/stackit_dns_zone_clone.md
@@ -0,0 +1,50 @@
+## stackit dns zone clone
+
+Clones a DNS zone
+
+### Synopsis
+
+Clones an existing DNS zone with all record sets to a new zone with a different name.
+
+```
+stackit dns zone clone ZONE_ID [flags]
+```
+
+### Examples
+
+```
+ Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com"
+ $ stackit dns zone clone xxx --dns-name www.my-zone.com
+
+ Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" and display name "new-zone"
+ $ stackit dns zone clone xxx --dns-name www.my-zone.com --name new-zone
+
+ Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" and adjust records "true"
+ $ stackit dns zone clone xxx --dns-name www.my-zone.com --adjust-records
+```
+
+### Options
+
+```
+ --adjust-records Sets content and replaces the DNS name of the original zone with the new DNS name of the cloned zone
+ --description string New description for the cloned zone
+ --dns-name string Fully qualified domain name of the new DNS zone to clone
+ -h, --help Help for "stackit dns zone clone"
+ --name string User given new name for the cloned zone
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones
+
diff --git a/docs/stackit_dns_zone_create.md b/docs/stackit_dns_zone_create.md
index 2ac88572c..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
@@ -46,6 +46,7 @@ stackit dns zone create [flags]
--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")
```
diff --git a/docs/stackit_dns_zone_delete.md b/docs/stackit_dns_zone_delete.md
index 04f96576f..466d51f72 100644
--- a/docs/stackit_dns_zone_delete.md
+++ b/docs/stackit_dns_zone_delete.md
@@ -30,6 +30,7 @@ stackit dns zone delete ZONE_ID [flags]
--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")
```
diff --git a/docs/stackit_dns_zone_describe.md b/docs/stackit_dns_zone_describe.md
index f65163566..896a3ef9d 100644
--- a/docs/stackit_dns_zone_describe.md
+++ b/docs/stackit_dns_zone_describe.md
@@ -33,6 +33,7 @@ stackit dns zone describe ZONE_ID [flags]
--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")
```
diff --git a/docs/stackit_dns_zone_list.md b/docs/stackit_dns_zone_list.md
index 099a67cf2..bb9e01fd1 100644
--- a/docs/stackit_dns_zone_list.md
+++ b/docs/stackit_dns_zone_list.md
@@ -46,6 +46,7 @@ stackit dns zone list [flags]
--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")
```
diff --git a/docs/stackit_dns_zone_update.md b/docs/stackit_dns_zone_update.md
index 334d7fbec..240885c1b 100644
--- a/docs/stackit_dns_zone_update.md
+++ b/docs/stackit_dns_zone_update.md
@@ -40,6 +40,7 @@ stackit dns zone update ZONE_ID [flags]
--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")
```
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.md b/docs/stackit_image.md
new file mode 100644
index 000000000..4120c26e6
--- /dev/null
+++ b/docs/stackit_image.md
@@ -0,0 +1,38 @@
+## stackit image
+
+Manage server images
+
+### Synopsis
+
+Manage the lifecycle of server images.
+
+```
+stackit image [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit image"
+```
+
+### 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 image create](./stackit_image_create.md) - Creates images
+* [stackit image delete](./stackit_image_delete.md) - Deletes an image
+* [stackit image describe](./stackit_image_describe.md) - Describes image
+* [stackit image list](./stackit_image_list.md) - Lists images
+* [stackit image update](./stackit_image_update.md) - Updates an image
+
diff --git a/docs/stackit_image_create.md b/docs/stackit_image_create.md
new file mode 100644
index 000000000..eb8a0a3e9
--- /dev/null
+++ b/docs/stackit_image_create.md
@@ -0,0 +1,68 @@
+## stackit image create
+
+Creates images
+
+### Synopsis
+
+Creates images.
+
+```
+stackit image create [flags]
+```
+
+### Examples
+
+```
+ Create an image with name 'my-new-image' from a raw disk image located in '/my/raw/image'
+ $ stackit image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image
+
+ 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.
+ --disk-format string The disk format of the image.
+ -h, --help Help for "stackit image create"
+ --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ --local-file-path string The path to the local disk image file.
+ --min-disk-size int Size in Gigabyte.
+ --min-ram int Size in Megabyte.
+ --name string The name of the image.
+ --nic-model string Sets virtual nic model.
+ --no-progress Show no progress indicator for upload.
+ --os string Enables OS specific optimizations.
+ --os-distro string Operating System Distribution.
+ --os-version string Version of the OS.
+ --protected Protected VM.
+ --rescue-bus string Sets the device bus when the image is used as a rescue image.
+ --rescue-device string Sets the device when the image is used as a rescue image.
+ --secure-boot Enables Secure Boot.
+ --uefi Enables UEFI boot. (default true)
+ --video-model string Sets Graphic device model.
+ --virtio-scsi Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.
+```
+
+### 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 image](./stackit_image.md) - Manage server images
+
diff --git a/docs/stackit_argus_instance_delete.md b/docs/stackit_image_delete.md
similarity index 58%
rename from docs/stackit_argus_instance_delete.md
rename to docs/stackit_image_delete.md
index b0c681607..bbe36d37d 100644
--- a/docs/stackit_argus_instance_delete.md
+++ b/docs/stackit_image_delete.md
@@ -1,26 +1,26 @@
-## stackit argus instance delete
+## stackit image delete
-Deletes an Argus instance
+Deletes an image
### Synopsis
-Deletes an Argus instance.
+Deletes an image by its internal ID.
```
-stackit argus instance delete INSTANCE_ID [flags]
+stackit image delete IMAGE_ID [flags]
```
### Examples
```
- Delete an Argus instance with ID "xxx"
- $ stackit argus instance delete xxx
+ Delete an image with ID "xxx"
+ $ stackit image delete xxx
```
### Options
```
- -h, --help Help for "stackit argus instance delete"
+ -h, --help Help for "stackit image delete"
```
### Options inherited from parent commands
@@ -30,10 +30,11 @@ stackit argus instance delete INSTANCE_ID [flags]
--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 argus instance](./stackit_argus_instance.md) - Provides functionality for Argus instances
+* [stackit image](./stackit_image.md) - Manage server images
diff --git a/docs/stackit_image_describe.md b/docs/stackit_image_describe.md
new file mode 100644
index 000000000..35403a150
--- /dev/null
+++ b/docs/stackit_image_describe.md
@@ -0,0 +1,40 @@
+## stackit image describe
+
+Describes image
+
+### Synopsis
+
+Describes an image by its internal ID.
+
+```
+stackit image describe IMAGE_ID [flags]
+```
+
+### Examples
+
+```
+ Describe image "xxx"
+ $ stackit image describe xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit image 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 image](./stackit_image.md) - Manage server images
+
diff --git a/docs/stackit_image_list.md b/docs/stackit_image_list.md
new file mode 100644
index 000000000..eae2a3409
--- /dev/null
+++ b/docs/stackit_image_list.md
@@ -0,0 +1,48 @@
+## stackit image list
+
+Lists images
+
+### Synopsis
+
+Lists images by their internal ID.
+
+```
+stackit image list [flags]
+```
+
+### Examples
+
+```
+ List all images
+ $ stackit image list
+
+ List images with label
+ $ stackit image list --label-selector ARM64,dev
+
+ List the first 10 images
+ $ stackit image list --limit=10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit image list"
+ --label-selector string Filter by label
+ --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 image](./stackit_image.md) - Manage server images
+
diff --git a/docs/stackit_image_update.md b/docs/stackit_image_update.md
new file mode 100644
index 000000000..d088d9962
--- /dev/null
+++ b/docs/stackit_image_update.md
@@ -0,0 +1,63 @@
+## stackit image update
+
+Updates an image
+
+### Synopsis
+
+Updates an image
+
+```
+stackit image update IMAGE_ID [flags]
+```
+
+### Examples
+
+```
+ Update the name of an image with ID "xxx"
+ $ stackit image update xxx --name my-new-name
+
+ Update the labels of an image with ID "xxx"
+ $ stackit image update xxx --labels label1=value1,label2=value2
+```
+
+### 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.
+ --disk-format string The disk format of the image.
+ -h, --help Help for "stackit image update"
+ --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ --min-disk-size int Size in Gigabyte.
+ --min-ram int Size in Megabyte.
+ --name string The name of the image.
+ --nic-model string Sets virtual nic model.
+ --os string Enables OS specific optimizations.
+ --os-distro string Operating System Distribution.
+ --os-version string Version of the OS.
+ --protected Protected VM.
+ --rescue-bus string Sets the device bus when the image is used as a rescue image.
+ --rescue-device string Sets the device when the image is used as a rescue image.
+ --secure-boot Enables Secure Boot.
+ --uefi Enables UEFI boot.
+ --video-model string Sets Graphic device model.
+ --virtio-scsi Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.
+```
+
+### 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 image](./stackit_image.md) - Manage server images
+
diff --git a/docs/stackit_key-pair.md b/docs/stackit_key-pair.md
new file mode 100644
index 000000000..6e3aff7fb
--- /dev/null
+++ b/docs/stackit_key-pair.md
@@ -0,0 +1,38 @@
+## stackit key-pair
+
+Provides functionality for SSH key pairs
+
+### Synopsis
+
+Provides functionality for SSH key pairs
+
+```
+stackit key-pair [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit key-pair"
+```
+
+### 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 key-pair create](./stackit_key-pair_create.md) - Creates a key pair
+* [stackit key-pair delete](./stackit_key-pair_delete.md) - Deletes a key pair
+* [stackit key-pair describe](./stackit_key-pair_describe.md) - Describes a key pair
+* [stackit key-pair list](./stackit_key-pair_list.md) - Lists all key pairs
+* [stackit key-pair update](./stackit_key-pair_update.md) - Updates a key pair
+
diff --git a/docs/stackit_key-pair_create.md b/docs/stackit_key-pair_create.md
new file mode 100644
index 000000000..04fbb9561
--- /dev/null
+++ b/docs/stackit_key-pair_create.md
@@ -0,0 +1,52 @@
+## stackit key-pair create
+
+Creates a key pair
+
+### Synopsis
+
+Creates a key pair.
+
+```
+stackit key-pair create [flags]
+```
+
+### Examples
+
+```
+ Create a new key pair with public-key "ssh-rsa xxx"
+ $ stackit key-pair create --public-key `ssh-rsa xxx`
+
+ Create a new key pair with public-key from file "/Users/username/.ssh/id_rsa.pub"
+ $ stackit key-pair create --public-key `@/Users/username/.ssh/id_rsa.pub`
+
+ Create a new key pair with name "KEY_PAIR_NAME" and public-key "ssh-rsa yyy"
+ $ stackit key-pair create --name KEY_PAIR_NAME --public-key `ssh-rsa yyy`
+
+ Create a new key pair with public-key "ssh-rsa xxx" and labels "key=value,key1=value1"
+ $ stackit key-pair create --public-key `ssh-rsa xxx` --labels key=value,key1=value1
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit key-pair create"
+ --labels stringToString Labels are key-value string pairs which can be attached to a key pair. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ --name string Key pair name
+ --public-key string Public key to be imported (format: ssh-rsa|ssh-ed25519)
+```
+
+### 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 key-pair](./stackit_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_key-pair_delete.md b/docs/stackit_key-pair_delete.md
new file mode 100644
index 000000000..b9dc10cda
--- /dev/null
+++ b/docs/stackit_key-pair_delete.md
@@ -0,0 +1,40 @@
+## stackit key-pair delete
+
+Deletes a key pair
+
+### Synopsis
+
+Deletes a key pair.
+
+```
+stackit key-pair delete KEY_PAIR_NAME [flags]
+```
+
+### Examples
+
+```
+ Delete key pair with name "KEY_PAIR_NAME"
+ $ stackit key-pair delete KEY_PAIR_NAME
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit key-pair 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 key-pair](./stackit_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_key-pair_describe.md b/docs/stackit_key-pair_describe.md
new file mode 100644
index 000000000..fa68ec225
--- /dev/null
+++ b/docs/stackit_key-pair_describe.md
@@ -0,0 +1,44 @@
+## stackit key-pair describe
+
+Describes a key pair
+
+### Synopsis
+
+Describes a key pair.
+
+```
+stackit key-pair describe KEY_PAIR_NAME [flags]
+```
+
+### Examples
+
+```
+ Get details about a key pair with name "KEY_PAIR_NAME"
+ $ stackit key-pair describe KEY_PAIR_NAME
+
+ Get only the SSH public key of a key pair with name "KEY_PAIR_NAME"
+ $ stackit key-pair describe KEY_PAIR_NAME --public-key
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit key-pair describe"
+ --public-key Show only the public 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 key-pair](./stackit_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_key-pair_list.md b/docs/stackit_key-pair_list.md
new file mode 100644
index 000000000..f4af20042
--- /dev/null
+++ b/docs/stackit_key-pair_list.md
@@ -0,0 +1,51 @@
+## stackit key-pair list
+
+Lists all key pairs
+
+### Synopsis
+
+Lists all key pairs.
+
+```
+stackit key-pair list [flags]
+```
+
+### Examples
+
+```
+ Lists all key pairs
+ $ stackit key-pair list
+
+ Lists all key pairs which contains the label xxx
+ $ stackit key-pair list --label-selector xxx
+
+ Lists all key pairs in JSON format
+ $ stackit key-pair list --output-format json
+
+ Lists up to 10 key pairs
+ $ stackit key-pair list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit key-pair list"
+ --label-selector string Filter by label
+ --limit int Number of key pairs 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 key-pair](./stackit_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_key-pair_update.md b/docs/stackit_key-pair_update.md
new file mode 100644
index 000000000..6c7399dd0
--- /dev/null
+++ b/docs/stackit_key-pair_update.md
@@ -0,0 +1,41 @@
+## stackit key-pair update
+
+Updates a key pair
+
+### Synopsis
+
+Updates a key pair.
+
+```
+stackit key-pair update KEY_PAIR_NAME [flags]
+```
+
+### Examples
+
+```
+ Update the labels of a key pair with name "KEY_PAIR_NAME" with "key=value,key1=value1"
+ $ stackit key-pair update KEY_PAIR_NAME --labels key=value,key1=value1
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit key-pair update"
+ --labels stringToString Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...' (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 key-pair](./stackit_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_load-balancer.md b/docs/stackit_load-balancer.md
index 8c154e2b8..77c14b00f 100644
--- a/docs/stackit_load-balancer.md
+++ b/docs/stackit_load-balancer.md
@@ -23,6 +23,7 @@ stackit load-balancer [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_create.md b/docs/stackit_load-balancer_create.md
index 054947308..4172ccb4d 100644
--- a/docs/stackit_load-balancer_create.md
+++ b/docs/stackit_load-balancer_create.md
@@ -41,6 +41,7 @@ stackit load-balancer create [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_delete.md b/docs/stackit_load-balancer_delete.md
index 4c8c895a9..07ee2712c 100644
--- a/docs/stackit_load-balancer_delete.md
+++ b/docs/stackit_load-balancer_delete.md
@@ -30,6 +30,7 @@ stackit load-balancer delete LOAD_BALANCER_NAME [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_describe.md b/docs/stackit_load-balancer_describe.md
index de7abebc8..8f3bd5da1 100644
--- a/docs/stackit_load-balancer_describe.md
+++ b/docs/stackit_load-balancer_describe.md
@@ -33,6 +33,7 @@ stackit load-balancer describe LOAD_BALANCER_NAME [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_generate-payload.md b/docs/stackit_load-balancer_generate-payload.md
index 07636cb2e..2cf2b15c7 100644
--- a/docs/stackit_load-balancer_generate-payload.md
+++ b/docs/stackit_load-balancer_generate-payload.md
@@ -43,6 +43,7 @@ stackit load-balancer generate-payload [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_list.md b/docs/stackit_load-balancer_list.md
index b73422bf6..3cc3749e9 100644
--- a/docs/stackit_load-balancer_list.md
+++ b/docs/stackit_load-balancer_list.md
@@ -37,6 +37,7 @@ stackit load-balancer list [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_observability-credentials.md b/docs/stackit_load-balancer_observability-credentials.md
index 187047d14..ae2e58a8e 100644
--- a/docs/stackit_load-balancer_observability-credentials.md
+++ b/docs/stackit_load-balancer_observability-credentials.md
@@ -4,7 +4,7 @@ Provides functionality for Load Balancer observability credentials
### Synopsis
-Provides functionality for Load Balancer observability credentials. These commands can be used to store and update existing credentials, which are valid to be used for Load Balancer observability. This means, e.g. when using Argus, first of all these credentials must be created for that Argus instance (by using "stackit argus credentials create") and then can be managed for a Load Balancer by using the commands in this group.
+Provides functionality for Load Balancer observability credentials. These commands can be used to store and update existing credentials, which are valid to be used for Load Balancer observability. This means, e.g. when using Observability, first of all these credentials must be created for that Observability instance (by using "stackit observability credentials create") and then can be managed for a Load Balancer by using the commands in this group.
```
stackit load-balancer observability-credentials [flags]
@@ -23,6 +23,7 @@ stackit load-balancer observability-credentials [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_observability-credentials_add.md b/docs/stackit_load-balancer_observability-credentials_add.md
index 8abbc2fe9..97afccbf4 100644
--- a/docs/stackit_load-balancer_observability-credentials_add.md
+++ b/docs/stackit_load-balancer_observability-credentials_add.md
@@ -4,7 +4,7 @@ Adds observability credentials to Load Balancer
### Synopsis
-Adds existing observability credentials (username and password) to Load Balancer. The credentials can be for Argus or another monitoring tool.
+Adds existing observability credentials (username and password) to Load Balancer. The credentials can be for Observability or another monitoring tool.
```
stackit load-balancer observability-credentials add [flags]
@@ -36,6 +36,7 @@ stackit load-balancer observability-credentials add [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_observability-credentials_cleanup.md b/docs/stackit_load-balancer_observability-credentials_cleanup.md
index f22f88994..58fcbe82e 100644
--- a/docs/stackit_load-balancer_observability-credentials_cleanup.md
+++ b/docs/stackit_load-balancer_observability-credentials_cleanup.md
@@ -30,6 +30,7 @@ stackit load-balancer observability-credentials cleanup [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_observability-credentials_delete.md b/docs/stackit_load-balancer_observability-credentials_delete.md
index 7c2965552..a2fcf8018 100644
--- a/docs/stackit_load-balancer_observability-credentials_delete.md
+++ b/docs/stackit_load-balancer_observability-credentials_delete.md
@@ -30,6 +30,7 @@ stackit load-balancer observability-credentials delete CREDENTIALS_REF [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_observability-credentials_describe.md b/docs/stackit_load-balancer_observability-credentials_describe.md
index 5feb0a190..c8ed19750 100644
--- a/docs/stackit_load-balancer_observability-credentials_describe.md
+++ b/docs/stackit_load-balancer_observability-credentials_describe.md
@@ -30,6 +30,7 @@ stackit load-balancer observability-credentials describe CREDENTIALS_REF [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_observability-credentials_list.md b/docs/stackit_load-balancer_observability-credentials_list.md
index 893fecdbf..2e86129d0 100644
--- a/docs/stackit_load-balancer_observability-credentials_list.md
+++ b/docs/stackit_load-balancer_observability-credentials_list.md
@@ -45,6 +45,7 @@ stackit load-balancer observability-credentials list [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_observability-credentials_update.md b/docs/stackit_load-balancer_observability-credentials_update.md
index 2ef125bbc..c0d95a31d 100644
--- a/docs/stackit_load-balancer_observability-credentials_update.md
+++ b/docs/stackit_load-balancer_observability-credentials_update.md
@@ -4,10 +4,10 @@ Updates observability credentials for Load Balancer
### Synopsis
-Updates existing observability credentials (username and password) for Load Balancer. The credentials can be for Argus or another monitoring tool.
+Updates existing observability credentials (username and password) for Load Balancer. The credentials can be for Observability or another monitoring tool.
```
-stackit load-balancer observability-credentials update [flags]
+stackit load-balancer observability-credentials update CREDENTIALS_REF [flags]
```
### Examples
@@ -36,6 +36,7 @@ stackit load-balancer observability-credentials update [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_quota.md b/docs/stackit_load-balancer_quota.md
index 9ffb0b5e3..62541ec3e 100644
--- a/docs/stackit_load-balancer_quota.md
+++ b/docs/stackit_load-balancer_quota.md
@@ -30,6 +30,7 @@ stackit load-balancer quota [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_target-pool.md b/docs/stackit_load-balancer_target-pool.md
index cce372cee..8356f0436 100644
--- a/docs/stackit_load-balancer_target-pool.md
+++ b/docs/stackit_load-balancer_target-pool.md
@@ -23,6 +23,7 @@ stackit load-balancer target-pool [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_target-pool_add-target.md b/docs/stackit_load-balancer_target-pool_add-target.md
index c5de949df..b6e1e8109 100644
--- a/docs/stackit_load-balancer_target-pool_add-target.md
+++ b/docs/stackit_load-balancer_target-pool_add-target.md
@@ -34,6 +34,7 @@ stackit load-balancer target-pool add-target TARGET_IP [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_target-pool_describe.md b/docs/stackit_load-balancer_target-pool_describe.md
index 25564de96..67fbe0580 100644
--- a/docs/stackit_load-balancer_target-pool_describe.md
+++ b/docs/stackit_load-balancer_target-pool_describe.md
@@ -34,6 +34,7 @@ stackit load-balancer target-pool describe TARGET_POOL_NAME [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_target-pool_remove-target.md b/docs/stackit_load-balancer_target-pool_remove-target.md
index af638f939..2d95b7abf 100644
--- a/docs/stackit_load-balancer_target-pool_remove-target.md
+++ b/docs/stackit_load-balancer_target-pool_remove-target.md
@@ -32,6 +32,7 @@ stackit load-balancer target-pool remove-target TARGET_IP [flags]
--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")
```
diff --git a/docs/stackit_load-balancer_update.md b/docs/stackit_load-balancer_update.md
index a2d9b771f..fc577b35b 100644
--- a/docs/stackit_load-balancer_update.md
+++ b/docs/stackit_load-balancer_update.md
@@ -41,6 +41,7 @@ stackit load-balancer update LOAD_BALANCER_NAME [flags]
--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")
```
diff --git a/docs/stackit_logme.md b/docs/stackit_logme.md
index 38a1419ab..edd1ec1e7 100644
--- a/docs/stackit_logme.md
+++ b/docs/stackit_logme.md
@@ -23,6 +23,7 @@ stackit logme [flags]
--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")
```
diff --git a/docs/stackit_logme_credentials.md b/docs/stackit_logme_credentials.md
index 0d2712c1d..f510d9854 100644
--- a/docs/stackit_logme_credentials.md
+++ b/docs/stackit_logme_credentials.md
@@ -23,6 +23,7 @@ stackit logme credentials [flags]
--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")
```
diff --git a/docs/stackit_logme_credentials_create.md b/docs/stackit_logme_credentials_create.md
index 2dc853cec..f20a9c583 100644
--- a/docs/stackit_logme_credentials_create.md
+++ b/docs/stackit_logme_credentials_create.md
@@ -35,6 +35,7 @@ stackit logme credentials create [flags]
--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")
```
diff --git a/docs/stackit_logme_credentials_delete.md b/docs/stackit_logme_credentials_delete.md
index 020b53c99..0925c4cbd 100644
--- a/docs/stackit_logme_credentials_delete.md
+++ b/docs/stackit_logme_credentials_delete.md
@@ -31,6 +31,7 @@ stackit logme credentials delete CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_logme_credentials_describe.md b/docs/stackit_logme_credentials_describe.md
index a882a1c70..96940297b 100644
--- a/docs/stackit_logme_credentials_describe.md
+++ b/docs/stackit_logme_credentials_describe.md
@@ -34,6 +34,7 @@ stackit logme credentials describe CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_logme_credentials_list.md b/docs/stackit_logme_credentials_list.md
index 2ae3d2bfa..3cd2a5164 100644
--- a/docs/stackit_logme_credentials_list.md
+++ b/docs/stackit_logme_credentials_list.md
@@ -38,6 +38,7 @@ stackit logme credentials list [flags]
--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")
```
diff --git a/docs/stackit_logme_instance.md b/docs/stackit_logme_instance.md
index 90c389041..54144eb80 100644
--- a/docs/stackit_logme_instance.md
+++ b/docs/stackit_logme_instance.md
@@ -23,6 +23,7 @@ stackit logme instance [flags]
--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")
```
diff --git a/docs/stackit_logme_instance_create.md b/docs/stackit_logme_instance_create.md
index 281d13e9b..a0af8584d 100644
--- a/docs/stackit_logme_instance_create.md
+++ b/docs/stackit_logme_instance_create.md
@@ -47,6 +47,7 @@ stackit logme instance create [flags]
--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")
```
diff --git a/docs/stackit_logme_instance_delete.md b/docs/stackit_logme_instance_delete.md
index 94bc52c53..34e9a8fc9 100644
--- a/docs/stackit_logme_instance_delete.md
+++ b/docs/stackit_logme_instance_delete.md
@@ -30,6 +30,7 @@ stackit logme instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_logme_instance_describe.md b/docs/stackit_logme_instance_describe.md
index 26241ca7a..c9f0f9bcc 100644
--- a/docs/stackit_logme_instance_describe.md
+++ b/docs/stackit_logme_instance_describe.md
@@ -33,6 +33,7 @@ stackit logme instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_logme_instance_list.md b/docs/stackit_logme_instance_list.md
index 9180286b2..20673a368 100644
--- a/docs/stackit_logme_instance_list.md
+++ b/docs/stackit_logme_instance_list.md
@@ -37,6 +37,7 @@ stackit logme instance list [flags]
--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")
```
diff --git a/docs/stackit_logme_instance_update.md b/docs/stackit_logme_instance_update.md
index d6ac88f29..02e3bb975 100644
--- a/docs/stackit_logme_instance_update.md
+++ b/docs/stackit_logme_instance_update.md
@@ -43,6 +43,7 @@ stackit logme instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_logme_plans.md b/docs/stackit_logme_plans.md
index f2450971f..87f7db2a3 100644
--- a/docs/stackit_logme_plans.md
+++ b/docs/stackit_logme_plans.md
@@ -37,6 +37,7 @@ stackit logme plans [flags]
--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")
```
diff --git a/docs/stackit_mariadb.md b/docs/stackit_mariadb.md
index 18d6875c2..b40107270 100644
--- a/docs/stackit_mariadb.md
+++ b/docs/stackit_mariadb.md
@@ -23,6 +23,7 @@ stackit mariadb [flags]
--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")
```
diff --git a/docs/stackit_mariadb_credentials.md b/docs/stackit_mariadb_credentials.md
index 34f79706f..ccfa3e470 100644
--- a/docs/stackit_mariadb_credentials.md
+++ b/docs/stackit_mariadb_credentials.md
@@ -23,6 +23,7 @@ stackit mariadb credentials [flags]
--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")
```
diff --git a/docs/stackit_mariadb_credentials_create.md b/docs/stackit_mariadb_credentials_create.md
index beb6159c6..e611ebeff 100644
--- a/docs/stackit_mariadb_credentials_create.md
+++ b/docs/stackit_mariadb_credentials_create.md
@@ -35,6 +35,7 @@ stackit mariadb credentials create [flags]
--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")
```
diff --git a/docs/stackit_mariadb_credentials_delete.md b/docs/stackit_mariadb_credentials_delete.md
index 812c4f7bc..e1b7bbee7 100644
--- a/docs/stackit_mariadb_credentials_delete.md
+++ b/docs/stackit_mariadb_credentials_delete.md
@@ -31,6 +31,7 @@ stackit mariadb credentials delete CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_mariadb_credentials_describe.md b/docs/stackit_mariadb_credentials_describe.md
index 79b828146..12f440607 100644
--- a/docs/stackit_mariadb_credentials_describe.md
+++ b/docs/stackit_mariadb_credentials_describe.md
@@ -34,6 +34,7 @@ stackit mariadb credentials describe CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_mariadb_credentials_list.md b/docs/stackit_mariadb_credentials_list.md
index cc49e23c4..99120fda8 100644
--- a/docs/stackit_mariadb_credentials_list.md
+++ b/docs/stackit_mariadb_credentials_list.md
@@ -38,6 +38,7 @@ stackit mariadb credentials list [flags]
--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")
```
diff --git a/docs/stackit_mariadb_instance.md b/docs/stackit_mariadb_instance.md
index 2da27c38b..3ace44fc6 100644
--- a/docs/stackit_mariadb_instance.md
+++ b/docs/stackit_mariadb_instance.md
@@ -23,6 +23,7 @@ stackit mariadb instance [flags]
--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")
```
diff --git a/docs/stackit_mariadb_instance_create.md b/docs/stackit_mariadb_instance_create.md
index 3a8a063b9..63bb11865 100644
--- a/docs/stackit_mariadb_instance_create.md
+++ b/docs/stackit_mariadb_instance_create.md
@@ -47,6 +47,7 @@ stackit mariadb instance create [flags]
--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")
```
diff --git a/docs/stackit_mariadb_instance_delete.md b/docs/stackit_mariadb_instance_delete.md
index 564057900..39ea03e35 100644
--- a/docs/stackit_mariadb_instance_delete.md
+++ b/docs/stackit_mariadb_instance_delete.md
@@ -30,6 +30,7 @@ stackit mariadb instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_mariadb_instance_describe.md b/docs/stackit_mariadb_instance_describe.md
index 13c5014e1..e864c29a6 100644
--- a/docs/stackit_mariadb_instance_describe.md
+++ b/docs/stackit_mariadb_instance_describe.md
@@ -33,6 +33,7 @@ stackit mariadb instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_mariadb_instance_list.md b/docs/stackit_mariadb_instance_list.md
index 301787ef6..2990fbc24 100644
--- a/docs/stackit_mariadb_instance_list.md
+++ b/docs/stackit_mariadb_instance_list.md
@@ -37,6 +37,7 @@ stackit mariadb instance list [flags]
--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")
```
diff --git a/docs/stackit_mariadb_instance_update.md b/docs/stackit_mariadb_instance_update.md
index 1c7712c00..2de6c1026 100644
--- a/docs/stackit_mariadb_instance_update.md
+++ b/docs/stackit_mariadb_instance_update.md
@@ -43,6 +43,7 @@ stackit mariadb instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_mariadb_plans.md b/docs/stackit_mariadb_plans.md
index 4ad4db981..f17b09ecd 100644
--- a/docs/stackit_mariadb_plans.md
+++ b/docs/stackit_mariadb_plans.md
@@ -37,6 +37,7 @@ stackit mariadb plans [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex.md b/docs/stackit_mongodbflex.md
index 3d6810d68..7f0746976 100644
--- a/docs/stackit_mongodbflex.md
+++ b/docs/stackit_mongodbflex.md
@@ -23,6 +23,7 @@ stackit mongodbflex [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_backup.md b/docs/stackit_mongodbflex_backup.md
index de46caf3a..e89b79ae5 100644
--- a/docs/stackit_mongodbflex_backup.md
+++ b/docs/stackit_mongodbflex_backup.md
@@ -23,6 +23,7 @@ stackit mongodbflex backup [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_backup_describe.md b/docs/stackit_mongodbflex_backup_describe.md
index bb422f064..287f325b4 100644
--- a/docs/stackit_mongodbflex_backup_describe.md
+++ b/docs/stackit_mongodbflex_backup_describe.md
@@ -34,6 +34,7 @@ stackit mongodbflex backup describe BACKUP_ID [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_backup_list.md b/docs/stackit_mongodbflex_backup_list.md
index 611ae3a22..87fa406ea 100644
--- a/docs/stackit_mongodbflex_backup_list.md
+++ b/docs/stackit_mongodbflex_backup_list.md
@@ -38,6 +38,7 @@ stackit mongodbflex backup list [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_backup_restore-jobs.md b/docs/stackit_mongodbflex_backup_restore-jobs.md
index 8b909182b..c91bcaf2f 100644
--- a/docs/stackit_mongodbflex_backup_restore-jobs.md
+++ b/docs/stackit_mongodbflex_backup_restore-jobs.md
@@ -38,6 +38,7 @@ stackit mongodbflex backup restore-jobs [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_backup_restore.md b/docs/stackit_mongodbflex_backup_restore.md
index 18dadd1a3..b506f1ce6 100644
--- a/docs/stackit_mongodbflex_backup_restore.md
+++ b/docs/stackit_mongodbflex_backup_restore.md
@@ -42,6 +42,7 @@ stackit mongodbflex backup restore [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_backup_schedule.md b/docs/stackit_mongodbflex_backup_schedule.md
index 945d5081b..fff1ef32c 100644
--- a/docs/stackit_mongodbflex_backup_schedule.md
+++ b/docs/stackit_mongodbflex_backup_schedule.md
@@ -34,6 +34,7 @@ stackit mongodbflex backup schedule [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_backup_update-schedule.md b/docs/stackit_mongodbflex_backup_update-schedule.md
index 872f52835..cfa492a84 100644
--- a/docs/stackit_mongodbflex_backup_update-schedule.md
+++ b/docs/stackit_mongodbflex_backup_update-schedule.md
@@ -42,6 +42,7 @@ stackit mongodbflex backup update-schedule [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_instance.md b/docs/stackit_mongodbflex_instance.md
index c38c90538..962f4bc8e 100644
--- a/docs/stackit_mongodbflex_instance.md
+++ b/docs/stackit_mongodbflex_instance.md
@@ -23,6 +23,7 @@ stackit mongodbflex instance [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_instance_create.md b/docs/stackit_mongodbflex_instance_create.md
index 2b424836f..151ff2dad 100644
--- a/docs/stackit_mongodbflex_instance_create.md
+++ b/docs/stackit_mongodbflex_instance_create.md
@@ -46,6 +46,7 @@ stackit mongodbflex instance create [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_instance_delete.md b/docs/stackit_mongodbflex_instance_delete.md
index 88718bb37..39d313068 100644
--- a/docs/stackit_mongodbflex_instance_delete.md
+++ b/docs/stackit_mongodbflex_instance_delete.md
@@ -30,6 +30,7 @@ stackit mongodbflex instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_instance_describe.md b/docs/stackit_mongodbflex_instance_describe.md
index 870da5e13..b37bd3605 100644
--- a/docs/stackit_mongodbflex_instance_describe.md
+++ b/docs/stackit_mongodbflex_instance_describe.md
@@ -33,6 +33,7 @@ stackit mongodbflex instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_instance_list.md b/docs/stackit_mongodbflex_instance_list.md
index 3b3995ed6..5abfe9cbc 100644
--- a/docs/stackit_mongodbflex_instance_list.md
+++ b/docs/stackit_mongodbflex_instance_list.md
@@ -37,6 +37,7 @@ stackit mongodbflex instance list [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_instance_update.md b/docs/stackit_mongodbflex_instance_update.md
index ef64d5088..a9475fbeb 100644
--- a/docs/stackit_mongodbflex_instance_update.md
+++ b/docs/stackit_mongodbflex_instance_update.md
@@ -43,6 +43,7 @@ stackit mongodbflex instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_options.md b/docs/stackit_mongodbflex_options.md
index b695104a8..d01544608 100644
--- a/docs/stackit_mongodbflex_options.md
+++ b/docs/stackit_mongodbflex_options.md
@@ -41,6 +41,7 @@ stackit mongodbflex options [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_user.md b/docs/stackit_mongodbflex_user.md
index 7b45211b7..ce6528aa4 100644
--- a/docs/stackit_mongodbflex_user.md
+++ b/docs/stackit_mongodbflex_user.md
@@ -23,6 +23,7 @@ stackit mongodbflex user [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_user_create.md b/docs/stackit_mongodbflex_user_create.md
index 6a6645be0..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
```
@@ -40,6 +40,7 @@ stackit mongodbflex user create [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_user_delete.md b/docs/stackit_mongodbflex_user_delete.md
index 6297faa1f..bf792ddea 100644
--- a/docs/stackit_mongodbflex_user_delete.md
+++ b/docs/stackit_mongodbflex_user_delete.md
@@ -32,6 +32,7 @@ stackit mongodbflex user delete USER_ID [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_user_describe.md b/docs/stackit_mongodbflex_user_describe.md
index 1a0b65443..15642c9c2 100644
--- a/docs/stackit_mongodbflex_user_describe.md
+++ b/docs/stackit_mongodbflex_user_describe.md
@@ -36,6 +36,7 @@ stackit mongodbflex user describe USER_ID [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_user_list.md b/docs/stackit_mongodbflex_user_list.md
index df3305a73..804abfe11 100644
--- a/docs/stackit_mongodbflex_user_list.md
+++ b/docs/stackit_mongodbflex_user_list.md
@@ -38,6 +38,7 @@ stackit mongodbflex user list [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_user_reset-password.md b/docs/stackit_mongodbflex_user_reset-password.md
index e269e1ff7..183885b5f 100644
--- a/docs/stackit_mongodbflex_user_reset-password.md
+++ b/docs/stackit_mongodbflex_user_reset-password.md
@@ -32,6 +32,7 @@ stackit mongodbflex user reset-password USER_ID [flags]
--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")
```
diff --git a/docs/stackit_mongodbflex_user_update.md b/docs/stackit_mongodbflex_user_update.md
index 3702eb36c..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
@@ -33,6 +33,7 @@ stackit mongodbflex user update USER_ID [flags]
--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")
```
diff --git a/docs/stackit_network-area.md b/docs/stackit_network-area.md
new file mode 100644
index 000000000..6f2d751f8
--- /dev/null
+++ b/docs/stackit_network-area.md
@@ -0,0 +1,41 @@
+## stackit network-area
+
+Provides functionality for STACKIT Network Area (SNA)
+
+### Synopsis
+
+Provides functionality for STACKIT Network Area (SNA).
+
+```
+stackit network-area [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area"
+```
+
+### 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 network-area create](./stackit_network-area_create.md) - Creates a STACKIT Network Area (SNA)
+* [stackit network-area delete](./stackit_network-area_delete.md) - Deletes a STACKIT Network Area (SNA)
+* [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 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
new file mode 100644
index 000000000..e9a28231d
--- /dev/null
+++ b/docs/stackit_network-area_create.md
@@ -0,0 +1,46 @@
+## stackit network-area create
+
+Creates a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Creates a STACKIT Network Area (SNA) in an organization.
+
+```
+stackit network-area create [flags]
+```
+
+### Examples
+
+```
+ 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-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
+
+```
+ -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
+
+```
+ -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)
+
diff --git a/docs/stackit_network-area_delete.md b/docs/stackit_network-area_delete.md
new file mode 100644
index 000000000..f7814d583
--- /dev/null
+++ b/docs/stackit_network-area_delete.md
@@ -0,0 +1,43 @@
+## stackit network-area delete
+
+Deletes a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Deletes a STACKIT Network Area (SNA) in an organization.
+If the SNA is attached to any projects, the deletion will fail
+
+
+```
+stackit network-area delete AREA_ID [flags]
+```
+
+### Examples
+
+```
+ Delete network area with ID "xxx" in organization with ID "yyy"
+ $ stackit network-area delete xxx --organization-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area delete"
+ --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](./stackit_network-area.md) - Provides functionality for STACKIT Network Area (SNA)
+
diff --git a/docs/stackit_network-area_describe.md b/docs/stackit_network-area_describe.md
new file mode 100644
index 000000000..a5656cfe7
--- /dev/null
+++ b/docs/stackit_network-area_describe.md
@@ -0,0 +1,48 @@
+## stackit network-area describe
+
+Shows details of a STACKIT Network Area
+
+### Synopsis
+
+Shows details of a STACKIT Network Area in an organization.
+
+```
+stackit network-area describe AREA_ID [flags]
+```
+
+### Examples
+
+```
+ Show details of a network area with ID "xxx" in organization with ID "yyy"
+ $ stackit network-area describe xxx --organization-id yyy
+
+ Show details of a network area with ID "xxx" in organization with ID "yyy" and show attached projects
+ $ stackit network-area describe xxx --organization-id yyy --show-attached-projects
+
+ Show details of a network area with ID "xxx" in organization with ID "yyy" in JSON format
+ $ stackit network-area describe xxx --organization-id yyy --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area describe"
+ --organization-id string Organization ID
+ --show-attached-projects Whether to show attached projects. If a network area has several attached projects, their retrieval may take some time and the output may be extensive.
+```
+
+### 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)
+
diff --git a/docs/stackit_network-area_list.md b/docs/stackit_network-area_list.md
new file mode 100644
index 000000000..74fcaf9d7
--- /dev/null
+++ b/docs/stackit_network-area_list.md
@@ -0,0 +1,52 @@
+## stackit network-area list
+
+Lists all STACKIT Network Areas (SNA) of an organization
+
+### Synopsis
+
+Lists all STACKIT Network Areas (SNA) of an organization.
+
+```
+stackit network-area list [flags]
+```
+
+### Examples
+
+```
+ Lists all network areas of organization "xxx"
+ $ stackit network-area list --organization-id xxx
+
+ Lists all network areas of organization "xxx" in JSON format
+ $ stackit network-area list --organization-id xxx --output-format json
+
+ Lists up to 10 network areas of organization "xxx"
+ $ stackit network-area list --organization-id xxx --limit 10
+
+ Lists all network areas of organization "xxx" which contains the label yyy
+ $ stackit network-area list --organization-id xxx --label-selector yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area list"
+ --label-selector string Filter by label
+ --limit int Maximum number of entries to list
+ --organization-id string Organization ID
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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)
+
diff --git a/docs/stackit_network-area_network-range.md b/docs/stackit_network-area_network-range.md
new file mode 100644
index 000000000..4e146f3d7
--- /dev/null
+++ b/docs/stackit_network-area_network-range.md
@@ -0,0 +1,37 @@
+## stackit network-area network-range
+
+Provides functionality for network ranges in STACKIT Network Areas
+
+### Synopsis
+
+Provides functionality for network ranges in STACKIT Network Areas.
+
+```
+stackit network-area network-range [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area network-range"
+```
+
+### 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 network-range create](./stackit_network-area_network-range_create.md) - Creates a network range in a STACKIT Network Area (SNA)
+* [stackit network-area network-range delete](./stackit_network-area_network-range_delete.md) - Deletes a network range in a STACKIT Network Area (SNA)
+* [stackit network-area network-range describe](./stackit_network-area_network-range_describe.md) - Shows details of a network range in a STACKIT Network Area (SNA)
+* [stackit network-area network-range list](./stackit_network-area_network-range_list.md) - Lists all network ranges in a STACKIT Network Area (SNA)
+
diff --git a/docs/stackit_network-area_network-range_create.md b/docs/stackit_network-area_network-range_create.md
new file mode 100644
index 000000000..c51b7399a
--- /dev/null
+++ b/docs/stackit_network-area_network-range_create.md
@@ -0,0 +1,43 @@
+## stackit network-area network-range create
+
+Creates a network range in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Creates a network range in a STACKIT Network Area (SNA).
+
+```
+stackit network-area network-range create [flags]
+```
+
+### Examples
+
+```
+ Create a network range in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"
+ $ stackit network-area network-range create --network-area-id xxx --organization-id yyy --network-range "1.1.1.0/24"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area network-range create"
+ --network-area-id string STACKIT Network Area (SNA) ID
+ --network-range string Network range to create in CIDR notation
+ --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 network-range](./stackit_network-area_network-range.md) - Provides functionality for network ranges in STACKIT Network Areas
+
diff --git a/docs/stackit_network-area_network-range_delete.md b/docs/stackit_network-area_network-range_delete.md
new file mode 100644
index 000000000..22626b6bf
--- /dev/null
+++ b/docs/stackit_network-area_network-range_delete.md
@@ -0,0 +1,42 @@
+## stackit network-area network-range delete
+
+Deletes a network range in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Deletes a network range in a STACKIT Network Area (SNA).
+
+```
+stackit network-area network-range delete NETWORK_RANGE_ID [flags]
+```
+
+### Examples
+
+```
+ Delete network range with id "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"
+ $ stackit network-area network-range delete xxx --network-area-id yyy --organization-id zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area network-range 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 network-range](./stackit_network-area_network-range.md) - Provides functionality for network ranges in STACKIT Network Areas
+
diff --git a/docs/stackit_network-area_network-range_describe.md b/docs/stackit_network-area_network-range_describe.md
new file mode 100644
index 000000000..5e1831f7f
--- /dev/null
+++ b/docs/stackit_network-area_network-range_describe.md
@@ -0,0 +1,42 @@
+## stackit network-area network-range describe
+
+Shows details of a network range in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Shows details of a network range in a STACKIT Network Area (SNA).
+
+```
+stackit network-area network-range describe NETWORK_RANGE_ID [flags]
+```
+
+### Examples
+
+```
+ Show details of a network range with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"
+ $ stackit network-area network-range describe xxx --network-area-id yyy --organization-id zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area network-range 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 network-range](./stackit_network-area_network-range.md) - Provides functionality for network ranges in STACKIT Network Areas
+
diff --git a/docs/stackit_network-area_network-range_list.md b/docs/stackit_network-area_network-range_list.md
new file mode 100644
index 000000000..f66857ce7
--- /dev/null
+++ b/docs/stackit_network-area_network-range_list.md
@@ -0,0 +1,49 @@
+## stackit network-area network-range list
+
+Lists all network ranges in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Lists all network ranges in a STACKIT Network Area (SNA).
+
+```
+stackit network-area network-range list [flags]
+```
+
+### Examples
+
+```
+ Lists all network ranges in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"
+ $ stackit network-area network-range list --network-area-id xxx --organization-id yyy
+
+ Lists all network ranges in a STACKIT Network Area with ID "xxx" in organization with ID "yyy" in JSON format
+ $ stackit network-area network-range list --network-area-id xxx --organization-id yyy --output-format json
+
+ Lists up to 10 network ranges in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"
+ $ stackit network-area network-range list --network-area-id xxx --organization-id yyy --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area network-range list"
+ --limit int Maximum number of entries to 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 network-range](./stackit_network-area_network-range.md) - Provides functionality for network ranges in STACKIT Network Areas
+
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.md b/docs/stackit_network-area_route.md
new file mode 100644
index 000000000..a5fb3f19d
--- /dev/null
+++ b/docs/stackit_network-area_route.md
@@ -0,0 +1,38 @@
+## stackit network-area route
+
+Provides functionality for static routes in STACKIT Network Areas
+
+### Synopsis
+
+Provides functionality for static routes in STACKIT Network Areas.
+
+```
+stackit network-area route [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area 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](./stackit_network-area.md) - Provides functionality for STACKIT Network Area (SNA)
+* [stackit network-area route create](./stackit_network-area_route_create.md) - Creates a static route in a STACKIT Network Area (SNA)
+* [stackit network-area route delete](./stackit_network-area_route_delete.md) - Deletes a static route in a STACKIT Network Area (SNA)
+* [stackit network-area route describe](./stackit_network-area_route_describe.md) - Shows details of a static route in a STACKIT Network Area (SNA)
+* [stackit network-area route list](./stackit_network-area_route_list.md) - Lists all static routes in a STACKIT Network Area (SNA)
+* [stackit network-area route update](./stackit_network-area_route_update.md) - Updates a static route in a STACKIT Network Area (SNA)
+
diff --git a/docs/stackit_network-area_route_create.md b/docs/stackit_network-area_route_create.md
new file mode 100644
index 000000000..ff697f896
--- /dev/null
+++ b/docs/stackit_network-area_route_create.md
@@ -0,0 +1,53 @@
+## stackit network-area route create
+
+Creates a static route in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Creates a static route in a STACKIT Network Area (SNA).
+This command is currently asynchonous only due to limitations in the waiting functionality of the SDK. This will be updated in a future release.
+
+
+```
+stackit network-area route create [flags]
+```
+
+### Examples
+
+```
+ 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 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-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
+```
+
+### 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 route](./stackit_network-area_route.md) - Provides functionality for static routes in STACKIT Network Areas
+
diff --git a/docs/stackit_network-area_route_delete.md b/docs/stackit_network-area_route_delete.md
new file mode 100644
index 000000000..fc95549b4
--- /dev/null
+++ b/docs/stackit_network-area_route_delete.md
@@ -0,0 +1,42 @@
+## stackit network-area route delete
+
+Deletes a static route in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Deletes a static route in a STACKIT Network Area (SNA).
+
+```
+stackit network-area route delete ROUTE_ID [flags]
+```
+
+### Examples
+
+```
+ Delete a static route with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"
+ $ stackit network-area route delete xxx --organization-id zzz --network-area-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area route delete"
+ --network-area-id string STACKIT 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 route](./stackit_network-area_route.md) - Provides functionality for static routes in STACKIT Network Areas
+
diff --git a/docs/stackit_network-area_route_describe.md b/docs/stackit_network-area_route_describe.md
new file mode 100644
index 000000000..fbacf05bd
--- /dev/null
+++ b/docs/stackit_network-area_route_describe.md
@@ -0,0 +1,45 @@
+## stackit network-area route describe
+
+Shows details of a static route in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Shows details of a static route in a STACKIT Network Area (SNA).
+
+```
+stackit network-area route describe ROUTE_ID [flags]
+```
+
+### Examples
+
+```
+ Show details of a static route with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"
+ $ stackit network-area route describe xxx --network-area-id yyy --organization-id zzz
+
+ Show details of a static route with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz" in JSON format
+ $ stackit network-area route describe xxx --network-area-id yyy --organization-id zzz --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area route describe"
+ --network-area-id string STACKIT 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 route](./stackit_network-area_route.md) - Provides functionality for static routes in STACKIT Network Areas
+
diff --git a/docs/stackit_network-area_route_list.md b/docs/stackit_network-area_route_list.md
new file mode 100644
index 000000000..ff0a6ab1e
--- /dev/null
+++ b/docs/stackit_network-area_route_list.md
@@ -0,0 +1,49 @@
+## stackit network-area route list
+
+Lists all static routes in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Lists all static routes in a STACKIT Network Area (SNA).
+
+```
+stackit network-area route list [flags]
+```
+
+### Examples
+
+```
+ Lists all static routes in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"
+ $ stackit network-area route list --network-area-id xxx --organization-id yyy
+
+ Lists all static routes in a STACKIT Network Area with ID "xxx" in organization with ID "yyy" in JSON format
+ $ stackit network-area route list --network-area-id xxx --organization-id yyy --output-format json
+
+ Lists up to 10 static routes in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"
+ $ stackit network-area route list --network-area-id xxx --organization-id yyy --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area route list"
+ --limit int Maximum number of entries to list
+ --network-area-id string STACKIT 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 route](./stackit_network-area_route.md) - Provides functionality for static routes in STACKIT Network Areas
+
diff --git a/docs/stackit_network-area_route_update.md b/docs/stackit_network-area_route_update.md
new file mode 100644
index 000000000..61e54d10f
--- /dev/null
+++ b/docs/stackit_network-area_route_update.md
@@ -0,0 +1,45 @@
+## stackit network-area route update
+
+Updates a static route in a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Updates a static route in a STACKIT Network Area (SNA).
+This command is currently asynchonous only due to limitations in the waiting functionality of the SDK. This will be updated in a future release.
+
+
+```
+stackit network-area route update ROUTE_ID [flags]
+```
+
+### Examples
+
+```
+ Updates the label(s) of a static route with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"
+ $ stackit network-area route update xxx --labels key=value,foo=bar --organization-id yyy --network-area-id zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-area 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 STACKIT 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 route](./stackit_network-area_route.md) - Provides functionality for static routes in STACKIT Network Areas
+
diff --git a/docs/stackit_network-area_update.md b/docs/stackit_network-area_update.md
new file mode 100644
index 000000000..77665f0e8
--- /dev/null
+++ b/docs/stackit_network-area_update.md
@@ -0,0 +1,43 @@
+## stackit network-area update
+
+Updates a STACKIT Network Area (SNA)
+
+### Synopsis
+
+Updates a STACKIT Network Area (SNA) in an organization.
+
+```
+stackit network-area update AREA_ID [flags]
+```
+
+### Examples
+
+```
+ Update network area with ID "xxx" in organization with ID "yyy" with new name "network-area-1-new"
+ $ stackit network-area update xxx --organization-id yyy --name network-area-1-new
+```
+
+### Options
+
+```
+ -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
+
+```
+ -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)
+
diff --git a/docs/stackit_network-interface.md b/docs/stackit_network-interface.md
new file mode 100644
index 000000000..b7be67c0c
--- /dev/null
+++ b/docs/stackit_network-interface.md
@@ -0,0 +1,38 @@
+## stackit network-interface
+
+Provides functionality for network interfaces
+
+### Synopsis
+
+Provides functionality for network interfaces.
+
+```
+stackit network-interface [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-interface"
+```
+
+### 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 network-interface create](./stackit_network-interface_create.md) - Creates a network interface
+* [stackit network-interface delete](./stackit_network-interface_delete.md) - Deletes a network interface
+* [stackit network-interface describe](./stackit_network-interface_describe.md) - Describes a network interface
+* [stackit network-interface list](./stackit_network-interface_list.md) - Lists all network interfaces of a network
+* [stackit network-interface update](./stackit_network-interface_update.md) - Updates a network interface
+
diff --git a/docs/stackit_network-interface_create.md b/docs/stackit_network-interface_create.md
new file mode 100644
index 000000000..5ff3acd27
--- /dev/null
+++ b/docs/stackit_network-interface_create.md
@@ -0,0 +1,51 @@
+## stackit network-interface create
+
+Creates a network interface
+
+### Synopsis
+
+Creates a network interface.
+
+```
+stackit network-interface create [flags]
+```
+
+### Examples
+
+```
+ Create a network interface for network with ID "xxx"
+ $ stackit network-interface create --network-id xxx
+
+ Create a network interface with allowed addresses, labels, a name, security groups and nic security enabled for network with ID "xxx"
+ $ 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
+```
+
+### Options
+
+```
+ --allowed-addresses strings List of allowed IPs
+ -h, --help Help for "stackit network-interface create"
+ -i, --ipv4 string IPv4 address
+ -s, --ipv6 string IPv6 address
+ --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ -n, --name string Network interface name
+ --network-id string Network ID
+ -b, --nic-security If this is set to false, then no security groups will apply to this network interface. (default true)
+ --security-groups strings List of security groups
+```
+
+### 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-interface](./stackit_network-interface.md) - Provides functionality for network interfaces
+
diff --git a/docs/stackit_network-interface_delete.md b/docs/stackit_network-interface_delete.md
new file mode 100644
index 000000000..624e0b83f
--- /dev/null
+++ b/docs/stackit_network-interface_delete.md
@@ -0,0 +1,41 @@
+## stackit network-interface delete
+
+Deletes a network interface
+
+### Synopsis
+
+Deletes a network interface.
+
+```
+stackit network-interface delete NIC_ID [flags]
+```
+
+### Examples
+
+```
+ Delete network interface with nic id "xxx" and network ID "yyy"
+ $ stackit network-interface delete xxx --network-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-interface delete"
+ --network-id string Network 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-interface](./stackit_network-interface.md) - Provides functionality for network interfaces
+
diff --git a/docs/stackit_network-interface_describe.md b/docs/stackit_network-interface_describe.md
new file mode 100644
index 000000000..159475be8
--- /dev/null
+++ b/docs/stackit_network-interface_describe.md
@@ -0,0 +1,47 @@
+## stackit network-interface describe
+
+Describes a network interface
+
+### Synopsis
+
+Describes a network interface.
+
+```
+stackit network-interface describe NIC_ID [flags]
+```
+
+### Examples
+
+```
+ Describes network interface with nic id "xxx" and network ID "yyy"
+ $ stackit network-interface describe xxx --network-id yyy
+
+ Describes network interface with nic id "xxx" and network ID "yyy" in JSON format
+ $ stackit network-interface describe xxx --network-id yyy --output-format json
+
+ Describes network interface with nic id "xxx" and network ID "yyy" in yaml format
+ $ stackit network-interface describe xxx --network-id yyy --output-format yaml
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-interface describe"
+ --network-id string Network 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-interface](./stackit_network-interface.md) - Provides functionality for network interfaces
+
diff --git a/docs/stackit_network-interface_list.md b/docs/stackit_network-interface_list.md
new file mode 100644
index 000000000..f202a6667
--- /dev/null
+++ b/docs/stackit_network-interface_list.md
@@ -0,0 +1,52 @@
+## stackit network-interface list
+
+Lists all network interfaces of a network
+
+### Synopsis
+
+Lists all network interfaces of a network.
+
+```
+stackit network-interface list [flags]
+```
+
+### Examples
+
+```
+ Lists all network interfaces with network ID "xxx"
+ $ stackit network-interface list --network-id xxx
+
+ Lists all network interfaces with network ID "xxx" which contains the label xxx
+ $ stackit network-interface list --network-id xxx --label-selector xxx
+
+ Lists all network interfaces with network ID "xxx" in JSON format
+ $ stackit network-interface list --network-id xxx --output-format json
+
+ Lists up to 10 network interfaces with network ID "xxx"
+ $ stackit network-interface list --network-id xxx --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network-interface list"
+ --label-selector string Filter by label
+ --limit int Maximum number of entries to list
+ --network-id string Network 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-interface](./stackit_network-interface.md) - Provides functionality for network interfaces
+
diff --git a/docs/stackit_network-interface_update.md b/docs/stackit_network-interface_update.md
new file mode 100644
index 000000000..0c3e2c322
--- /dev/null
+++ b/docs/stackit_network-interface_update.md
@@ -0,0 +1,52 @@
+## stackit network-interface update
+
+Updates a network interface
+
+### Synopsis
+
+Updates a network interface.
+
+```
+stackit network-interface update NIC_ID [flags]
+```
+
+### Examples
+
+```
+ Updates a network interface with nic id "xxx" and network-id "yyy" to new allowed addresses "1.1.1.1,8.8.8.8,9.9.9.9" and new labels "key=value,key2=value2"
+ $ stackit network-interface update xxx --network-id yyy --allowed-addresses "1.1.1.1,8.8.8.8,9.9.9.9" --labels key=value,key2=value2
+
+ Updates a network interface with nic id "xxx" and network-id "yyy" with new name "nic-name-new"
+ $ stackit network-interface update xxx --network-id yyy --name nic-name-new
+
+ Updates a network interface with nic id "xxx" and network-id "yyy" to include the security group "zzz"
+ $ stackit network-interface update xxx --network-id yyy --security-groups zzz
+```
+
+### Options
+
+```
+ --allowed-addresses strings List of allowed IPs
+ -h, --help Help for "stackit network-interface update"
+ --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ -n, --name string Network interface name
+ --network-id string Network ID
+ -b, --nic-security If this is set to false, then no security groups will apply to this network interface. (default true)
+ --security-groups strings List of security groups
+```
+
+### 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-interface](./stackit_network-interface.md) - Provides functionality for network interfaces
+
diff --git a/docs/stackit_network.md b/docs/stackit_network.md
new file mode 100644
index 000000000..f196fe7b6
--- /dev/null
+++ b/docs/stackit_network.md
@@ -0,0 +1,38 @@
+## stackit network
+
+Provides functionality for networks
+
+### Synopsis
+
+Provides functionality for networks.
+
+```
+stackit network [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network"
+```
+
+### 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 network create](./stackit_network_create.md) - Creates a network
+* [stackit network delete](./stackit_network_delete.md) - Deletes a network
+* [stackit network describe](./stackit_network_describe.md) - Shows details of a network
+* [stackit network list](./stackit_network_list.md) - Lists all networks of a project
+* [stackit network update](./stackit_network_update.md) - Updates a network
+
diff --git a/docs/stackit_network_create.md b/docs/stackit_network_create.md
new file mode 100644
index 000000000..146264977
--- /dev/null
+++ b/docs/stackit_network_create.md
@@ -0,0 +1,68 @@
+## stackit network create
+
+Creates a network
+
+### Synopsis
+
+Creates a network.
+
+```
+stackit network create [flags]
+```
+
+### Examples
+
+```
+ Create a network with name "network-1"
+ $ stackit network create --name network-1
+
+ Create a non-routed network with name "network-1"
+ $ stackit network create --name network-1 --non-routed
+
+ Create a network with name "network-1" and no gateway
+ $ stackit network create --name network-1 --no-ipv4-gateway
+
+ Create a network with name "network-1" and labels "key=value,key1=value1"
+ $ 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 --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"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network create"
+ --ipv4-dns-name-servers strings List of DNS name servers for IPv4. Nameservers cannot be defined for routed networks
+ --ipv4-gateway string The IPv4 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway
+ --ipv4-prefix string The IPv4 prefix of the network (CIDR)
+ --ipv4-prefix-length int The prefix length of the IPv4 network
+ --ipv6-dns-name-servers strings List of DNS name servers for IPv6. Nameservers cannot be defined for routed networks
+ --ipv6-gateway string The IPv6 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway
+ --ipv6-prefix string The IPv6 prefix of the network (CIDR)
+ --ipv6-prefix-length int The prefix length of the IPv6 network
+ --labels stringToString Labels are key-value string pairs which can be attached to a network. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ -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
+ --non-routed If set to true, the network is not routed and therefore not accessible from other networks
+```
+
+### 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](./stackit_network.md) - Provides functionality for networks
+
diff --git a/docs/stackit_network_delete.md b/docs/stackit_network_delete.md
new file mode 100644
index 000000000..5fb62e6e3
--- /dev/null
+++ b/docs/stackit_network_delete.md
@@ -0,0 +1,42 @@
+## stackit network delete
+
+Deletes a network
+
+### Synopsis
+
+Deletes a network.
+If the network is still in use, the deletion will fail
+
+
+```
+stackit network delete NETWORK_ID [flags]
+```
+
+### Examples
+
+```
+ Delete network with ID "xxx"
+ $ stackit network delete xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network 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 network](./stackit_network.md) - Provides functionality for networks
+
diff --git a/docs/stackit_network_describe.md b/docs/stackit_network_describe.md
new file mode 100644
index 000000000..d7298e4e5
--- /dev/null
+++ b/docs/stackit_network_describe.md
@@ -0,0 +1,43 @@
+## stackit network describe
+
+Shows details of a network
+
+### Synopsis
+
+Shows details of a network.
+
+```
+stackit network describe NETWORK_ID [flags]
+```
+
+### Examples
+
+```
+ Show details of a network with ID "xxx"
+ $ stackit network describe xxx
+
+ Show details of a network with ID "xxx" in JSON format
+ $ stackit network describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network 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 network](./stackit_network.md) - Provides functionality for networks
+
diff --git a/docs/stackit_network_list.md b/docs/stackit_network_list.md
new file mode 100644
index 000000000..1b4febd39
--- /dev/null
+++ b/docs/stackit_network_list.md
@@ -0,0 +1,51 @@
+## stackit network list
+
+Lists all networks of a project
+
+### Synopsis
+
+Lists all network of a project.
+
+```
+stackit network list [flags]
+```
+
+### Examples
+
+```
+ Lists all networks
+ $ stackit network list
+
+ Lists all networks in JSON format
+ $ stackit network list --output-format json
+
+ Lists up to 10 networks
+ $ stackit network list --limit 10
+
+ Lists all networks which contains the label xxx
+ $ stackit network list --label-selector xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network list"
+ --label-selector string Filter by label
+ --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 network](./stackit_network.md) - Provides functionality for networks
+
diff --git a/docs/stackit_network_update.md b/docs/stackit_network_update.md
new file mode 100644
index 000000000..313ce68fa
--- /dev/null
+++ b/docs/stackit_network_update.md
@@ -0,0 +1,57 @@
+## stackit network update
+
+Updates a network
+
+### Synopsis
+
+Updates a network.
+
+```
+stackit network update NETWORK_ID [flags]
+```
+
+### Examples
+
+```
+ Update network with ID "xxx" with new name "network-1-new"
+ $ stackit network update xxx --name network-1-new
+
+ Update network with ID "xxx" with no gateway
+ $ stackit network update --no-ipv4-gateway
+
+ Update IPv4 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 --ipv4-dns-name-servers "2.2.2.2" --ipv4-gateway "10.1.2.3"
+
+ 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"
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit network update"
+ --ipv4-dns-name-servers strings List of DNS name servers IPv4. Nameservers cannot be defined for routed networks
+ --ipv4-gateway string The IPv4 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway
+ --ipv6-dns-name-servers strings List of DNS name servers for IPv6. Nameservers cannot be defined for routed networks
+ --ipv6-gateway string The IPv6 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway
+ --labels stringToString Labels are key-value string pairs which can be attached to a network. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ -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
+```
+
+### 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](./stackit_network.md) - Provides functionality for networks
+
diff --git a/docs/stackit_object-storage.md b/docs/stackit_object-storage.md
index 5d7def58c..5caa02380 100644
--- a/docs/stackit_object-storage.md
+++ b/docs/stackit_object-storage.md
@@ -23,6 +23,7 @@ stackit object-storage [flags]
--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")
```
diff --git a/docs/stackit_object-storage_bucket.md b/docs/stackit_object-storage_bucket.md
index 54a2cfdf8..ac5d3b600 100644
--- a/docs/stackit_object-storage_bucket.md
+++ b/docs/stackit_object-storage_bucket.md
@@ -23,6 +23,7 @@ stackit object-storage bucket [flags]
--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")
```
diff --git a/docs/stackit_object-storage_bucket_create.md b/docs/stackit_object-storage_bucket_create.md
index f22115b06..1c07eb540 100644
--- a/docs/stackit_object-storage_bucket_create.md
+++ b/docs/stackit_object-storage_bucket_create.md
@@ -30,6 +30,7 @@ stackit object-storage bucket create BUCKET_NAME [flags]
--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")
```
diff --git a/docs/stackit_object-storage_bucket_delete.md b/docs/stackit_object-storage_bucket_delete.md
index 56380a641..d512e4625 100644
--- a/docs/stackit_object-storage_bucket_delete.md
+++ b/docs/stackit_object-storage_bucket_delete.md
@@ -30,6 +30,7 @@ stackit object-storage bucket delete BUCKET_NAME [flags]
--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")
```
diff --git a/docs/stackit_object-storage_bucket_describe.md b/docs/stackit_object-storage_bucket_describe.md
index 7520a8217..256269aa9 100644
--- a/docs/stackit_object-storage_bucket_describe.md
+++ b/docs/stackit_object-storage_bucket_describe.md
@@ -33,6 +33,7 @@ stackit object-storage bucket describe BUCKET_NAME [flags]
--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")
```
diff --git a/docs/stackit_object-storage_bucket_list.md b/docs/stackit_object-storage_bucket_list.md
index 11cd639fb..b77f0eecb 100644
--- a/docs/stackit_object-storage_bucket_list.md
+++ b/docs/stackit_object-storage_bucket_list.md
@@ -37,6 +37,7 @@ stackit object-storage bucket list [flags]
--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")
```
diff --git a/docs/stackit_object-storage_credentials-group.md b/docs/stackit_object-storage_credentials-group.md
index 30db660a7..d20daee64 100644
--- a/docs/stackit_object-storage_credentials-group.md
+++ b/docs/stackit_object-storage_credentials-group.md
@@ -23,6 +23,7 @@ stackit object-storage credentials-group [flags]
--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")
```
diff --git a/docs/stackit_object-storage_credentials-group_create.md b/docs/stackit_object-storage_credentials-group_create.md
index 291be494a..2dd99a88e 100644
--- a/docs/stackit_object-storage_credentials-group_create.md
+++ b/docs/stackit_object-storage_credentials-group_create.md
@@ -31,6 +31,7 @@ stackit object-storage credentials-group create [flags]
--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")
```
diff --git a/docs/stackit_object-storage_credentials-group_delete.md b/docs/stackit_object-storage_credentials-group_delete.md
index d9d94802a..4d97f4722 100644
--- a/docs/stackit_object-storage_credentials-group_delete.md
+++ b/docs/stackit_object-storage_credentials-group_delete.md
@@ -30,6 +30,7 @@ stackit object-storage credentials-group delete CREDENTIALS_GROUP_ID [flags]
--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")
```
diff --git a/docs/stackit_object-storage_credentials-group_list.md b/docs/stackit_object-storage_credentials-group_list.md
index bd91a8942..2cb925069 100644
--- a/docs/stackit_object-storage_credentials-group_list.md
+++ b/docs/stackit_object-storage_credentials-group_list.md
@@ -37,6 +37,7 @@ stackit object-storage credentials-group list [flags]
--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")
```
diff --git a/docs/stackit_object-storage_credentials.md b/docs/stackit_object-storage_credentials.md
index 6d602dd20..2427b5f42 100644
--- a/docs/stackit_object-storage_credentials.md
+++ b/docs/stackit_object-storage_credentials.md
@@ -23,6 +23,7 @@ stackit object-storage credentials [flags]
--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")
```
diff --git a/docs/stackit_object-storage_credentials_create.md b/docs/stackit_object-storage_credentials_create.md
index 6e91d075a..95df0d984 100644
--- a/docs/stackit_object-storage_credentials_create.md
+++ b/docs/stackit_object-storage_credentials_create.md
@@ -35,6 +35,7 @@ stackit object-storage credentials create [flags]
--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")
```
diff --git a/docs/stackit_object-storage_credentials_delete.md b/docs/stackit_object-storage_credentials_delete.md
index b14154ec8..91768a44e 100644
--- a/docs/stackit_object-storage_credentials_delete.md
+++ b/docs/stackit_object-storage_credentials_delete.md
@@ -31,6 +31,7 @@ stackit object-storage credentials delete CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_object-storage_credentials_list.md b/docs/stackit_object-storage_credentials_list.md
index 647c024b5..08c2e94c2 100644
--- a/docs/stackit_object-storage_credentials_list.md
+++ b/docs/stackit_object-storage_credentials_list.md
@@ -38,6 +38,7 @@ stackit object-storage credentials list [flags]
--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")
```
diff --git a/docs/stackit_object-storage_disable.md b/docs/stackit_object-storage_disable.md
index e386c006f..c9aef3194 100644
--- a/docs/stackit_object-storage_disable.md
+++ b/docs/stackit_object-storage_disable.md
@@ -30,6 +30,7 @@ stackit object-storage disable [flags]
--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")
```
diff --git a/docs/stackit_object-storage_enable.md b/docs/stackit_object-storage_enable.md
index 6bf93bb80..9de05bfb3 100644
--- a/docs/stackit_object-storage_enable.md
+++ b/docs/stackit_object-storage_enable.md
@@ -30,6 +30,7 @@ stackit object-storage enable [flags]
--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")
```
diff --git a/docs/stackit_observability.md b/docs/stackit_observability.md
new file mode 100644
index 000000000..393f10ff0
--- /dev/null
+++ b/docs/stackit_observability.md
@@ -0,0 +1,38 @@
+## stackit observability
+
+Provides functionality for Observability
+
+### Synopsis
+
+Provides functionality for Observability.
+
+```
+stackit observability [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability"
+```
+
+### 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 observability credentials](./stackit_observability_credentials.md) - Provides functionality for Observability credentials
+* [stackit observability grafana](./stackit_observability_grafana.md) - Provides functionality for the Grafana configuration of Observability instances
+* [stackit observability instance](./stackit_observability_instance.md) - Provides functionality for Observability instances
+* [stackit observability plans](./stackit_observability_plans.md) - Lists all Observability service plans
+* [stackit observability scrape-config](./stackit_observability_scrape-config.md) - Provides functionality for scrape configurations in Observability
+
diff --git a/docs/stackit_observability_credentials.md b/docs/stackit_observability_credentials.md
new file mode 100644
index 000000000..661db4f24
--- /dev/null
+++ b/docs/stackit_observability_credentials.md
@@ -0,0 +1,36 @@
+## stackit observability credentials
+
+Provides functionality for Observability credentials
+
+### Synopsis
+
+Provides functionality for Observability credentials.
+
+```
+stackit observability credentials [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability credentials"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability](./stackit_observability.md) - Provides functionality for Observability
+* [stackit observability credentials create](./stackit_observability_credentials_create.md) - Creates credentials for an Observability instance.
+* [stackit observability credentials delete](./stackit_observability_credentials_delete.md) - Deletes credentials of an Observability instance
+* [stackit observability credentials list](./stackit_observability_credentials_list.md) - Lists the usernames of all credentials for an Observability instance
+
diff --git a/docs/stackit_argus_credentials_create.md b/docs/stackit_observability_credentials_create.md
similarity index 51%
rename from docs/stackit_argus_credentials_create.md
rename to docs/stackit_observability_credentials_create.md
index 9a5c0de17..e850b8418 100644
--- a/docs/stackit_argus_credentials_create.md
+++ b/docs/stackit_observability_credentials_create.md
@@ -1,27 +1,27 @@
-## stackit argus credentials create
+## stackit observability credentials create
-Creates credentials for an Argus instance.
+Creates credentials for an Observability instance.
### Synopsis
-Creates credentials (username and password) for an Argus instance.
+Creates credentials (username and password) for an Observability instance.
The credentials will be generated and included in the response. You won't be able to retrieve the password later.
```
-stackit argus credentials create [flags]
+stackit observability credentials create [flags]
```
### Examples
```
- Create credentials for Argus instance with ID "xxx"
- $ stackit argus credentials create --instance-id xxx
+ Create credentials for Observability instance with ID "xxx"
+ $ stackit observability credentials create --instance-id xxx
```
### Options
```
- -h, --help Help for "stackit argus credentials create"
+ -h, --help Help for "stackit observability credentials create"
--instance-id string Instance ID
```
@@ -32,10 +32,11 @@ stackit argus credentials create [flags]
--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 argus credentials](./stackit_argus_credentials.md) - Provides functionality for Argus credentials
+* [stackit observability credentials](./stackit_observability_credentials.md) - Provides functionality for Observability credentials
diff --git a/docs/stackit_observability_credentials_delete.md b/docs/stackit_observability_credentials_delete.md
new file mode 100644
index 000000000..98b927431
--- /dev/null
+++ b/docs/stackit_observability_credentials_delete.md
@@ -0,0 +1,41 @@
+## stackit observability credentials delete
+
+Deletes credentials of an Observability instance
+
+### Synopsis
+
+Deletes credentials of an Observability instance.
+
+```
+stackit observability credentials delete USERNAME [flags]
+```
+
+### Examples
+
+```
+ Delete credentials of username "xxx" for Observability instance with ID "yyy"
+ $ stackit observability credentials delete xxx --instance-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability credentials delete"
+ --instance-id string Instance ID
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability credentials](./stackit_observability_credentials.md) - Provides functionality for Observability credentials
+
diff --git a/docs/stackit_observability_credentials_list.md b/docs/stackit_observability_credentials_list.md
new file mode 100644
index 000000000..905f6658d
--- /dev/null
+++ b/docs/stackit_observability_credentials_list.md
@@ -0,0 +1,48 @@
+## stackit observability credentials list
+
+Lists the usernames of all credentials for an Observability instance
+
+### Synopsis
+
+Lists the usernames of all credentials for an Observability instance.
+
+```
+stackit observability credentials list [flags]
+```
+
+### Examples
+
+```
+ List the usernames of all credentials for an Observability instance with ID "xxx"
+ $ stackit observability credentials list --instance-id xxx
+
+ List the usernames of all credentials for an Observability instance in JSON format
+ $ stackit observability credentials list --instance-id xxx --output-format json
+
+ List the usernames of up to 10 credentials for an Observability instance
+ $ stackit observability credentials list --instance-id xxx --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability credentials list"
+ --instance-id string Instance ID
+ --limit int Maximum number of entries to list
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability credentials](./stackit_observability_credentials.md) - Provides functionality for Observability credentials
+
diff --git a/docs/stackit_observability_grafana.md b/docs/stackit_observability_grafana.md
new file mode 100644
index 000000000..57f7a6440
--- /dev/null
+++ b/docs/stackit_observability_grafana.md
@@ -0,0 +1,36 @@
+## stackit observability grafana
+
+Provides functionality for the Grafana configuration of Observability instances
+
+### Synopsis
+
+Provides functionality for the Grafana configuration of Observability instances.
+
+```
+stackit observability grafana [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability grafana"
+```
+
+### 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 observability](./stackit_observability.md) - Provides functionality for Observability
+* [stackit observability grafana describe](./stackit_observability_grafana_describe.md) - Shows details of the Grafana configuration of an Observability instance
+* [stackit observability grafana public-read-access](./stackit_observability_grafana_public-read-access.md) - Enable or disable public read access for Grafana in Observability instances
+* [stackit observability grafana single-sign-on](./stackit_observability_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Observability instances
+
diff --git a/docs/stackit_observability_grafana_describe.md b/docs/stackit_observability_grafana_describe.md
new file mode 100644
index 000000000..4eea4982a
--- /dev/null
+++ b/docs/stackit_observability_grafana_describe.md
@@ -0,0 +1,49 @@
+## stackit observability grafana describe
+
+Shows details of the Grafana configuration of an Observability instance
+
+### Synopsis
+
+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]
+```
+
+### Examples
+
+```
+ 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
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability grafana describe"
+ -s, --show-password Show password in output
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability grafana](./stackit_observability_grafana.md) - Provides functionality for the Grafana configuration of Observability instances
+
diff --git a/docs/stackit_observability_grafana_public-read-access.md b/docs/stackit_observability_grafana_public-read-access.md
new file mode 100644
index 000000000..39db9ef60
--- /dev/null
+++ b/docs/stackit_observability_grafana_public-read-access.md
@@ -0,0 +1,36 @@
+## stackit observability grafana public-read-access
+
+Enable or disable public read access for Grafana in Observability instances
+
+### Synopsis
+
+Enable or disable public read access for Grafana in Observability instances.
+When enabled, anyone can access the Grafana dashboards of the instance without logging in. Otherwise, a login is required.
+
+```
+stackit observability grafana public-read-access [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability grafana public-read-access"
+```
+
+### 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 observability grafana](./stackit_observability_grafana.md) - Provides functionality for the Grafana configuration of Observability instances
+* [stackit observability grafana public-read-access disable](./stackit_observability_grafana_public-read-access_disable.md) - Disables public read access for Grafana on Observability instances
+* [stackit observability grafana public-read-access enable](./stackit_observability_grafana_public-read-access_enable.md) - Enables public read access for Grafana on Observability instances
+
diff --git a/docs/stackit_observability_grafana_public-read-access_disable.md b/docs/stackit_observability_grafana_public-read-access_disable.md
new file mode 100644
index 000000000..02566e9f8
--- /dev/null
+++ b/docs/stackit_observability_grafana_public-read-access_disable.md
@@ -0,0 +1,41 @@
+## stackit observability grafana public-read-access disable
+
+Disables public read access for Grafana on Observability instances
+
+### Synopsis
+
+Disables public read access for Grafana on Observability instances.
+When disabled, a login is required to access the Grafana dashboards of the instance. Otherwise, anyone can access the dashboards.
+
+```
+stackit observability grafana public-read-access disable INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Disable public read access for Grafana on an Observability instance with ID "xxx"
+ $ stackit observability grafana public-read-access disable xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability grafana public-read-access disable"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability grafana public-read-access](./stackit_observability_grafana_public-read-access.md) - Enable or disable public read access for Grafana in Observability instances
+
diff --git a/docs/stackit_observability_grafana_public-read-access_enable.md b/docs/stackit_observability_grafana_public-read-access_enable.md
new file mode 100644
index 000000000..7b71bbb4e
--- /dev/null
+++ b/docs/stackit_observability_grafana_public-read-access_enable.md
@@ -0,0 +1,41 @@
+## stackit observability grafana public-read-access enable
+
+Enables public read access for Grafana on Observability instances
+
+### Synopsis
+
+Enables public read access for Grafana on Observability instances.
+When enabled, anyone can access the Grafana dashboards of the instance without logging in. Otherwise, a login is required.
+
+```
+stackit observability grafana public-read-access enable INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Enable public read access for Grafana on an Observability instance with ID "xxx"
+ $ stackit observability grafana public-read-access enable xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability grafana public-read-access enable"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability grafana public-read-access](./stackit_observability_grafana_public-read-access.md) - Enable or disable public read access for Grafana in Observability instances
+
diff --git a/docs/stackit_observability_grafana_single-sign-on.md b/docs/stackit_observability_grafana_single-sign-on.md
new file mode 100644
index 000000000..dfba3ff2e
--- /dev/null
+++ b/docs/stackit_observability_grafana_single-sign-on.md
@@ -0,0 +1,36 @@
+## stackit observability grafana single-sign-on
+
+Enable or disable single sign-on for Grafana in Observability instances
+
+### Synopsis
+
+Enable or disable single sign-on for Grafana in Observability instances.
+When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.
+
+```
+stackit observability grafana single-sign-on [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability grafana single-sign-on"
+```
+
+### 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 observability grafana](./stackit_observability_grafana.md) - Provides functionality for the Grafana configuration of Observability instances
+* [stackit observability grafana single-sign-on disable](./stackit_observability_grafana_single-sign-on_disable.md) - Disables single sign-on for Grafana on Observability instances
+* [stackit observability grafana single-sign-on enable](./stackit_observability_grafana_single-sign-on_enable.md) - Enables single sign-on for Grafana on Observability instances
+
diff --git a/docs/stackit_observability_grafana_single-sign-on_disable.md b/docs/stackit_observability_grafana_single-sign-on_disable.md
new file mode 100644
index 000000000..e4907acfa
--- /dev/null
+++ b/docs/stackit_observability_grafana_single-sign-on_disable.md
@@ -0,0 +1,41 @@
+## stackit observability grafana single-sign-on disable
+
+Disables single sign-on for Grafana on Observability instances
+
+### Synopsis
+
+Disables single sign-on for Grafana on Observability instances.
+When disabled for an instance, the generic OAuth2 authentication is used for that instance.
+
+```
+stackit observability grafana single-sign-on disable INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Disable single sign-on for Grafana on an Observability instance with ID "xxx"
+ $ stackit observability grafana single-sign-on disable xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability grafana single-sign-on disable"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability grafana single-sign-on](./stackit_observability_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Observability instances
+
diff --git a/docs/stackit_observability_grafana_single-sign-on_enable.md b/docs/stackit_observability_grafana_single-sign-on_enable.md
new file mode 100644
index 000000000..f083b1536
--- /dev/null
+++ b/docs/stackit_observability_grafana_single-sign-on_enable.md
@@ -0,0 +1,41 @@
+## stackit observability grafana single-sign-on enable
+
+Enables single sign-on for Grafana on Observability instances
+
+### Synopsis
+
+Enables single sign-on for Grafana on Observability instances.
+When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.
+
+```
+stackit observability grafana single-sign-on enable INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Enable single sign-on for Grafana on an Observability instance with ID "xxx"
+ $ stackit observability grafana single-sign-on enable xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability grafana single-sign-on enable"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability grafana single-sign-on](./stackit_observability_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Observability instances
+
diff --git a/docs/stackit_observability_instance.md b/docs/stackit_observability_instance.md
new file mode 100644
index 000000000..4b7350a5f
--- /dev/null
+++ b/docs/stackit_observability_instance.md
@@ -0,0 +1,38 @@
+## stackit observability instance
+
+Provides functionality for Observability instances
+
+### Synopsis
+
+Provides functionality for Observability instances.
+
+```
+stackit observability instance [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability 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 observability](./stackit_observability.md) - Provides functionality for Observability
+* [stackit observability instance create](./stackit_observability_instance_create.md) - Creates an Observability instance
+* [stackit observability instance delete](./stackit_observability_instance_delete.md) - Deletes an Observability instance
+* [stackit observability instance describe](./stackit_observability_instance_describe.md) - Shows details of an Observability instance
+* [stackit observability instance list](./stackit_observability_instance_list.md) - Lists all Observability instances
+* [stackit observability instance update](./stackit_observability_instance_update.md) - Updates an Observability instance
+
diff --git a/docs/stackit_observability_instance_create.md b/docs/stackit_observability_instance_create.md
new file mode 100644
index 000000000..994fda4ba
--- /dev/null
+++ b/docs/stackit_observability_instance_create.md
@@ -0,0 +1,46 @@
+## stackit observability instance create
+
+Creates an Observability instance
+
+### Synopsis
+
+Creates an Observability instance.
+
+```
+stackit observability instance create [flags]
+```
+
+### Examples
+
+```
+ Create an Observability instance with name "my-instance" and specify plan by name
+ $ stackit observability instance create --name my-instance --plan-name Monitoring-Starter-EU01
+
+ Create an Observability instance with name "my-instance" and specify plan by ID
+ $ stackit observability instance create --name my-instance --plan-id xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability instance create"
+ -n, --name string Instance name
+ --plan-id string Plan ID
+ --plan-name string Plan name
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability instance](./stackit_observability_instance.md) - Provides functionality for Observability instances
+
diff --git a/docs/stackit_observability_instance_delete.md b/docs/stackit_observability_instance_delete.md
new file mode 100644
index 000000000..30005abe4
--- /dev/null
+++ b/docs/stackit_observability_instance_delete.md
@@ -0,0 +1,40 @@
+## stackit observability instance delete
+
+Deletes an Observability instance
+
+### Synopsis
+
+Deletes an Observability instance.
+
+```
+stackit observability instance delete INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Delete an Observability instance with ID "xxx"
+ $ stackit Observability instance delete xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability 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 observability instance](./stackit_observability_instance.md) - Provides functionality for Observability instances
+
diff --git a/docs/stackit_observability_instance_describe.md b/docs/stackit_observability_instance_describe.md
new file mode 100644
index 000000000..d8f50173c
--- /dev/null
+++ b/docs/stackit_observability_instance_describe.md
@@ -0,0 +1,43 @@
+## stackit observability instance describe
+
+Shows details of an Observability instance
+
+### Synopsis
+
+Shows details of an Observability instance.
+
+```
+stackit observability instance describe INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Get details of an Observability instance with ID "xxx"
+ $ stackit observability instance describe xxx
+
+ Get details of an Observability instance with ID "xxx" in JSON format
+ $ stackit observability instance describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability 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 observability instance](./stackit_observability_instance.md) - Provides functionality for Observability instances
+
diff --git a/docs/stackit_observability_instance_list.md b/docs/stackit_observability_instance_list.md
new file mode 100644
index 000000000..18062dfa0
--- /dev/null
+++ b/docs/stackit_observability_instance_list.md
@@ -0,0 +1,47 @@
+## stackit observability instance list
+
+Lists all Observability instances
+
+### Synopsis
+
+Lists all Observability instances.
+
+```
+stackit observability instance list [flags]
+```
+
+### Examples
+
+```
+ List all Observability instances
+ $ stackit observability instance list
+
+ List all Observability instances in JSON format
+ $ stackit observability instance list --output-format json
+
+ List up to 10 Observability instances
+ $ stackit observability instance list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability 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 observability instance](./stackit_observability_instance.md) - Provides functionality for Observability instances
+
diff --git a/docs/stackit_observability_instance_update.md b/docs/stackit_observability_instance_update.md
new file mode 100644
index 000000000..fdbcc88f4
--- /dev/null
+++ b/docs/stackit_observability_instance_update.md
@@ -0,0 +1,49 @@
+## stackit observability instance update
+
+Updates an Observability instance
+
+### Synopsis
+
+Updates an Observability instance.
+
+```
+stackit observability instance update INSTANCE_ID [flags]
+```
+
+### Examples
+
+```
+ Update the plan of an Observability instance with ID "xxx" by specifying the plan ID
+ $ stackit observability instance update xxx --plan-id yyy
+
+ Update the plan of an Observability instance with ID "xxx" by specifying the plan name
+ $ stackit observability instance update xxx --plan-name Frontend-Starter-EU01
+
+ Update the name of an Observability instance with ID "xxx"
+ $ stackit observability instance update xxx --name new-instance-name
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability instance update"
+ -n, --name string Instance name
+ --plan-id string Plan ID
+ --plan-name string Plan name
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability instance](./stackit_observability_instance.md) - Provides functionality for Observability instances
+
diff --git a/docs/stackit_observability_plans.md b/docs/stackit_observability_plans.md
new file mode 100644
index 000000000..c0fb3846d
--- /dev/null
+++ b/docs/stackit_observability_plans.md
@@ -0,0 +1,47 @@
+## stackit observability plans
+
+Lists all Observability service plans
+
+### Synopsis
+
+Lists all Observability service plans.
+
+```
+stackit observability plans [flags]
+```
+
+### Examples
+
+```
+ List all Observability service plans
+ $ stackit observability plans
+
+ List all Observability service plans in JSON format
+ $ stackit observability plans --output-format json
+
+ List up to 10 Observability service plans
+ $ stackit observability plans --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability plans"
+ --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 observability](./stackit_observability.md) - Provides functionality for Observability
+
diff --git a/docs/stackit_observability_scrape-config.md b/docs/stackit_observability_scrape-config.md
new file mode 100644
index 000000000..cecb2058b
--- /dev/null
+++ b/docs/stackit_observability_scrape-config.md
@@ -0,0 +1,39 @@
+## stackit observability scrape-config
+
+Provides functionality for scrape configurations in Observability
+
+### Synopsis
+
+Provides functionality for scrape configurations in Observability.
+
+```
+stackit observability scrape-config [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability scrape-config"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability](./stackit_observability.md) - Provides functionality for Observability
+* [stackit observability scrape-config create](./stackit_observability_scrape-config_create.md) - Creates a scrape configuration for an Observability instance
+* [stackit observability scrape-config delete](./stackit_observability_scrape-config_delete.md) - Deletes a scrape configuration from an Observability instance
+* [stackit observability scrape-config describe](./stackit_observability_scrape-config_describe.md) - Shows details of a scrape configuration from an Observability instance
+* [stackit observability scrape-config generate-payload](./stackit_observability_scrape-config_generate-payload.md) - Generates a payload to create/update scrape configurations for an Observability instance
+* [stackit observability scrape-config list](./stackit_observability_scrape-config_list.md) - Lists all scrape configurations of an Observability instance
+* [stackit observability scrape-config update](./stackit_observability_scrape-config_update.md) - Updates a scrape configuration of an Observability instance
+
diff --git a/docs/stackit_observability_scrape-config_create.md b/docs/stackit_observability_scrape-config_create.md
new file mode 100644
index 000000000..f8db966bb
--- /dev/null
+++ b/docs/stackit_observability_scrape-config_create.md
@@ -0,0 +1,56 @@
+## stackit observability scrape-config create
+
+Creates a scrape configuration for an Observability instance
+
+### Synopsis
+
+Creates a scrape configuration job for an Observability instance.
+The payload can be provided as a JSON string or a file path prefixed with "@".
+If no payload is provided, a default payload will be used.
+See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_create for information regarding the payload structure.
+
+```
+stackit observability scrape-config create [flags]
+```
+
+### Examples
+
+```
+ Create a scrape configuration on Observability instance "xxx" using default configuration
+ $ stackit observability scrape-config create
+
+ Create a scrape configuration on Observability instance "xxx" using an API payload sourced from the file "./payload.json"
+ $ stackit observability scrape-config create --payload @./payload.json --instance-id xxx
+
+ Create a scrape configuration on Observability instance "xxx" using an API payload provided as a JSON string
+ $ stackit observability scrape-config create --payload "{...}" --instance-id xxx
+
+ Generate a payload with default values, and adapt it with custom values for the different configuration options
+ $ stackit observability scrape-config generate-payload > ./payload.json
+
+ $ stackit observability scrape-config create --payload @./payload.json --instance-id xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability scrape-config create"
+ --instance-id string Instance ID
+ --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit observability scrape-config generate-payload")
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability scrape-config](./stackit_observability_scrape-config.md) - Provides functionality for scrape configurations in Observability
+
diff --git a/docs/stackit_observability_scrape-config_delete.md b/docs/stackit_observability_scrape-config_delete.md
new file mode 100644
index 000000000..1ea9678ee
--- /dev/null
+++ b/docs/stackit_observability_scrape-config_delete.md
@@ -0,0 +1,41 @@
+## stackit observability scrape-config delete
+
+Deletes a scrape configuration from an Observability instance
+
+### Synopsis
+
+Deletes a scrape configuration from an Observability instance.
+
+```
+stackit observability scrape-config delete JOB_NAME [flags]
+```
+
+### Examples
+
+```
+ Delete a scrape configuration job with name "my-config" from Observability instance "xxx"
+ $ stackit observability scrape-config delete my-config --instance-id xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability scrape-config delete"
+ --instance-id string Instance ID
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability scrape-config](./stackit_observability_scrape-config.md) - Provides functionality for scrape configurations in Observability
+
diff --git a/docs/stackit_observability_scrape-config_describe.md b/docs/stackit_observability_scrape-config_describe.md
new file mode 100644
index 000000000..de6ad62cd
--- /dev/null
+++ b/docs/stackit_observability_scrape-config_describe.md
@@ -0,0 +1,44 @@
+## stackit observability scrape-config describe
+
+Shows details of a scrape configuration from an Observability instance
+
+### Synopsis
+
+Shows details of a scrape configuration from an Observability instance.
+
+```
+stackit observability scrape-config describe JOB_NAME [flags]
+```
+
+### Examples
+
+```
+ Get details of a scrape configuration with name "my-config" from Observability instance "xxx"
+ $ stackit observability scrape-config describe my-config --instance-id xxx
+
+ Get details of a scrape configuration with name "my-config" from Observability instance "xxx" in JSON format
+ $ stackit observability scrape-config describe my-config --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability scrape-config describe"
+ --instance-id string Instance ID
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability scrape-config](./stackit_observability_scrape-config.md) - Provides functionality for scrape configurations in Observability
+
diff --git a/docs/stackit_argus_scrape-config_generate-payload.md b/docs/stackit_observability_scrape-config_generate-payload.md
similarity index 62%
rename from docs/stackit_argus_scrape-config_generate-payload.md
rename to docs/stackit_observability_scrape-config_generate-payload.md
index a6d725a27..2cc4d0ad0 100644
--- a/docs/stackit_argus_scrape-config_generate-payload.md
+++ b/docs/stackit_observability_scrape-config_generate-payload.md
@@ -1,43 +1,43 @@
-## stackit argus scrape-config generate-payload
+## stackit observability scrape-config generate-payload
-Generates a payload to create/update scrape configurations for an Argus instance
+Generates a payload to create/update scrape configurations for an Observability instance
### Synopsis
Generates a JSON payload with values to be used as --payload input for scrape configurations creation or update.
This command can be used to generate a payload to update an existing scrape config or to create a new scrape config job.
-To update an existing scrape config job, provide the job name and the instance ID of the Argus instance.
+To update an existing scrape config job, provide the job name and the instance ID of the Observability instance.
To obtain a default payload to create a new scrape config job, run the command with no flags.
Note that some of the default values provided, such as the job name, the metrics path and URL of the targets, should be adapted to your use case.
See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_create for information regarding the payload structure.
```
-stackit argus scrape-config generate-payload [flags]
+stackit observability scrape-config generate-payload [flags]
```
### Examples
```
Generate a Create payload with default values, and adapt it with custom values for the different configuration options
- $ stackit argus scrape-config generate-payload --file-path ./payload.json
+ $ stackit observability scrape-config generate-payload --file-path ./payload.json
- $ stackit argus scrape-config create my-config --payload @./payload.json
+ $ stackit observability scrape-config create my-config --payload @./payload.json
- Generate an Update payload with the values of an existing configuration named "my-config" for Argus instance xxx, and adapt it with custom values for the different configuration options
- $ stackit argus scrape-config generate-payload --job-name my-config --instance-id xxx --file-path ./payload.json
+ Generate an Update payload with the values of an existing configuration named "my-config" for Observability instance xxx, and adapt it with custom values for the different configuration options
+ $ stackit observability scrape-config generate-payload --job-name my-config --instance-id xxx --file-path ./payload.json
- $ stackit argus scrape-config update my-config --payload @./payload.json
+ $ stackit observability scrape-config update my-config --payload @./payload.json
- Generate an Update payload with the values of an existing configuration named "my-config" for Argus instance xxx, and preview it in the terminal
- $ stackit argus scrape-config generate-payload --job-name my-config --instance-id xxx
+ 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
```
### Options
```
-f, --file-path string If set, writes the payload to the given file. If unset, writes the payload to the standard output
- -h, --help Help for "stackit argus scrape-config generate-payload"
+ -h, --help Help for "stackit observability scrape-config generate-payload"
--instance-id string Instance ID
-n, --job-name string If set, generates an update payload with the current state of the given scrape config. If unset, generates a create payload with default values
```
@@ -49,10 +49,11 @@ stackit argus scrape-config generate-payload [flags]
--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 argus scrape-config](./stackit_argus_scrape-config.md) - Provides functionality for scrape configurations in Argus
+* [stackit observability scrape-config](./stackit_observability_scrape-config.md) - Provides functionality for scrape configurations in Observability
diff --git a/docs/stackit_observability_scrape-config_list.md b/docs/stackit_observability_scrape-config_list.md
new file mode 100644
index 000000000..0db93b027
--- /dev/null
+++ b/docs/stackit_observability_scrape-config_list.md
@@ -0,0 +1,48 @@
+## stackit observability scrape-config list
+
+Lists all scrape configurations of an Observability instance
+
+### Synopsis
+
+Lists all scrape configurations of an Observability instance.
+
+```
+stackit observability scrape-config list [flags]
+```
+
+### Examples
+
+```
+ List all scrape configurations of Observability instance "xxx"
+ $ stackit observability scrape-config list --instance-id xxx
+
+ List all scrape configurations of Observability instance "xxx" in JSON format
+ $ stackit observability scrape-config list --instance-id xxx --output-format json
+
+ List up to 10 scrape configurations of Observability instance "xxx"
+ $ stackit observability scrape-config list --instance-id xxx --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability scrape-config list"
+ --instance-id string Instance ID
+ --limit int Maximum number of entries to list
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability scrape-config](./stackit_observability_scrape-config.md) - Provides functionality for scrape configurations in Observability
+
diff --git a/docs/stackit_observability_scrape-config_update.md b/docs/stackit_observability_scrape-config_update.md
new file mode 100644
index 000000000..a92e5cfb5
--- /dev/null
+++ b/docs/stackit_observability_scrape-config_update.md
@@ -0,0 +1,52 @@
+## stackit observability scrape-config update
+
+Updates a scrape configuration of an Observability instance
+
+### Synopsis
+
+Updates a scrape configuration of an Observability instance.
+The payload can be provided as a JSON string or a file path prefixed with "@".
+See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_update for information regarding the payload structure.
+
+```
+stackit observability scrape-config update JOB_NAME [flags]
+```
+
+### Examples
+
+```
+ Update a scrape configuration with name "my-config" from Observability instance "xxx", using an API payload sourced from the file "./payload.json"
+ $ stackit observability scrape-config update my-config --payload @./payload.json --instance-id xxx
+
+ Update an scrape configuration with name "my-config" from Observability instance "xxx", using an API payload provided as a JSON string
+ $ stackit observability scrape-config update my-config --payload "{...}" --instance-id xxx
+
+ Generate a payload with the current values of a scrape configuration, and adapt it with custom values for the different configuration options
+ $ stackit observability scrape-config generate-payload --job-name my-config > ./payload.json
+
+ $ stackit observability scrape-configs update my-config --payload @./payload.json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit observability scrape-config update"
+ --instance-id string Instance ID
+ --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 observability scrape-config](./stackit_observability_scrape-config.md) - Provides functionality for scrape configurations in Observability
+
diff --git a/docs/stackit_opensearch.md b/docs/stackit_opensearch.md
index 30af3bd1f..c83f878ba 100644
--- a/docs/stackit_opensearch.md
+++ b/docs/stackit_opensearch.md
@@ -23,6 +23,7 @@ stackit opensearch [flags]
--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")
```
diff --git a/docs/stackit_opensearch_credentials.md b/docs/stackit_opensearch_credentials.md
index 13358215e..2af0661e5 100644
--- a/docs/stackit_opensearch_credentials.md
+++ b/docs/stackit_opensearch_credentials.md
@@ -23,6 +23,7 @@ stackit opensearch credentials [flags]
--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")
```
diff --git a/docs/stackit_opensearch_credentials_create.md b/docs/stackit_opensearch_credentials_create.md
index c90ae9f11..dce634d44 100644
--- a/docs/stackit_opensearch_credentials_create.md
+++ b/docs/stackit_opensearch_credentials_create.md
@@ -35,6 +35,7 @@ stackit opensearch credentials create [flags]
--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")
```
diff --git a/docs/stackit_opensearch_credentials_delete.md b/docs/stackit_opensearch_credentials_delete.md
index e19e2a091..34af83bbd 100644
--- a/docs/stackit_opensearch_credentials_delete.md
+++ b/docs/stackit_opensearch_credentials_delete.md
@@ -31,6 +31,7 @@ stackit opensearch credentials delete CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_opensearch_credentials_describe.md b/docs/stackit_opensearch_credentials_describe.md
index bb0095b67..5f7c93e61 100644
--- a/docs/stackit_opensearch_credentials_describe.md
+++ b/docs/stackit_opensearch_credentials_describe.md
@@ -34,6 +34,7 @@ stackit opensearch credentials describe CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_opensearch_credentials_list.md b/docs/stackit_opensearch_credentials_list.md
index 6aea2e1c7..6cc855daa 100644
--- a/docs/stackit_opensearch_credentials_list.md
+++ b/docs/stackit_opensearch_credentials_list.md
@@ -38,6 +38,7 @@ stackit opensearch credentials list [flags]
--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")
```
diff --git a/docs/stackit_opensearch_instance.md b/docs/stackit_opensearch_instance.md
index 26182779c..2b2df6283 100644
--- a/docs/stackit_opensearch_instance.md
+++ b/docs/stackit_opensearch_instance.md
@@ -23,6 +23,7 @@ stackit opensearch instance [flags]
--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")
```
diff --git a/docs/stackit_opensearch_instance_create.md b/docs/stackit_opensearch_instance_create.md
index ef67d29f1..77b5aada1 100644
--- a/docs/stackit_opensearch_instance_create.md
+++ b/docs/stackit_opensearch_instance_create.md
@@ -48,6 +48,7 @@ stackit opensearch instance create [flags]
--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")
```
diff --git a/docs/stackit_opensearch_instance_delete.md b/docs/stackit_opensearch_instance_delete.md
index 49c4ab8ee..2783bc403 100644
--- a/docs/stackit_opensearch_instance_delete.md
+++ b/docs/stackit_opensearch_instance_delete.md
@@ -30,6 +30,7 @@ stackit opensearch instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_opensearch_instance_describe.md b/docs/stackit_opensearch_instance_describe.md
index 3aafa406d..8246df4f3 100644
--- a/docs/stackit_opensearch_instance_describe.md
+++ b/docs/stackit_opensearch_instance_describe.md
@@ -33,6 +33,7 @@ stackit opensearch instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_opensearch_instance_list.md b/docs/stackit_opensearch_instance_list.md
index a67f9821f..36904c646 100644
--- a/docs/stackit_opensearch_instance_list.md
+++ b/docs/stackit_opensearch_instance_list.md
@@ -37,6 +37,7 @@ stackit opensearch instance list [flags]
--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")
```
diff --git a/docs/stackit_opensearch_instance_update.md b/docs/stackit_opensearch_instance_update.md
index 5d688fdc3..8e174c120 100644
--- a/docs/stackit_opensearch_instance_update.md
+++ b/docs/stackit_opensearch_instance_update.md
@@ -44,6 +44,7 @@ stackit opensearch instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_opensearch_plans.md b/docs/stackit_opensearch_plans.md
index f733314ab..5617d7e2b 100644
--- a/docs/stackit_opensearch_plans.md
+++ b/docs/stackit_opensearch_plans.md
@@ -37,6 +37,7 @@ stackit opensearch plans [flags]
--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")
```
diff --git a/docs/stackit_organization.md b/docs/stackit_organization.md
index 0fefe9cb2..f1bbaedde 100644
--- a/docs/stackit_organization.md
+++ b/docs/stackit_organization.md
@@ -24,6 +24,7 @@ stackit organization [flags]
--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")
```
diff --git a/docs/stackit_organization_member.md b/docs/stackit_organization_member.md
index 496aa4466..9a0c0233d 100644
--- a/docs/stackit_organization_member.md
+++ b/docs/stackit_organization_member.md
@@ -23,6 +23,7 @@ stackit organization member [flags]
--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")
```
diff --git a/docs/stackit_organization_member_add.md b/docs/stackit_organization_member_add.md
index 0e5a50491..c3946d22c 100644
--- a/docs/stackit_organization_member_add.md
+++ b/docs/stackit_organization_member_add.md
@@ -36,6 +36,7 @@ stackit organization member add SUBJECT [flags]
--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")
```
diff --git a/docs/stackit_organization_member_list.md b/docs/stackit_organization_member_list.md
index 2263ce2a4..feb052304 100644
--- a/docs/stackit_organization_member_list.md
+++ b/docs/stackit_organization_member_list.md
@@ -40,6 +40,7 @@ stackit organization member list [flags]
--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")
```
diff --git a/docs/stackit_organization_member_remove.md b/docs/stackit_organization_member_remove.md
index 8c425a22c..f1a876d67 100644
--- a/docs/stackit_organization_member_remove.md
+++ b/docs/stackit_organization_member_remove.md
@@ -38,6 +38,7 @@ stackit organization member remove SUBJECT [flags]
--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")
```
diff --git a/docs/stackit_organization_role.md b/docs/stackit_organization_role.md
index 2a5d2f75a..6be031740 100644
--- a/docs/stackit_organization_role.md
+++ b/docs/stackit_organization_role.md
@@ -23,6 +23,7 @@ stackit organization role [flags]
--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")
```
diff --git a/docs/stackit_organization_role_list.md b/docs/stackit_organization_role_list.md
index 0228eca5c..265b9967e 100644
--- a/docs/stackit_organization_role_list.md
+++ b/docs/stackit_organization_role_list.md
@@ -38,6 +38,7 @@ stackit organization role list [flags]
--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")
```
diff --git a/docs/stackit_postgresflex.md b/docs/stackit_postgresflex.md
index 005ec9f19..125604dea 100644
--- a/docs/stackit_postgresflex.md
+++ b/docs/stackit_postgresflex.md
@@ -23,6 +23,7 @@ stackit postgresflex [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_backup.md b/docs/stackit_postgresflex_backup.md
index c1caacfce..2fcac02b7 100644
--- a/docs/stackit_postgresflex_backup.md
+++ b/docs/stackit_postgresflex_backup.md
@@ -23,6 +23,7 @@ stackit postgresflex backup [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_backup_describe.md b/docs/stackit_postgresflex_backup_describe.md
index cb5171718..bc506d775 100644
--- a/docs/stackit_postgresflex_backup_describe.md
+++ b/docs/stackit_postgresflex_backup_describe.md
@@ -34,6 +34,7 @@ stackit postgresflex backup describe BACKUP_ID [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_backup_list.md b/docs/stackit_postgresflex_backup_list.md
index 92b8373f7..fcdc7a536 100644
--- a/docs/stackit_postgresflex_backup_list.md
+++ b/docs/stackit_postgresflex_backup_list.md
@@ -38,6 +38,7 @@ stackit postgresflex backup list [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_backup_update-schedule.md b/docs/stackit_postgresflex_backup_update-schedule.md
index 7971a9b89..50a369194 100644
--- a/docs/stackit_postgresflex_backup_update-schedule.md
+++ b/docs/stackit_postgresflex_backup_update-schedule.md
@@ -32,6 +32,7 @@ stackit postgresflex backup update-schedule [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_instance.md b/docs/stackit_postgresflex_instance.md
index 679dfc2a2..bfd19a6c9 100644
--- a/docs/stackit_postgresflex_instance.md
+++ b/docs/stackit_postgresflex_instance.md
@@ -23,6 +23,7 @@ stackit postgresflex instance [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_instance_clone.md b/docs/stackit_postgresflex_instance_clone.md
index d158b15b8..ce0203986 100644
--- a/docs/stackit_postgresflex_instance_clone.md
+++ b/docs/stackit_postgresflex_instance_clone.md
@@ -39,6 +39,7 @@ stackit postgresflex instance clone INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_instance_create.md b/docs/stackit_postgresflex_instance_create.md
index ea5c1f247..b4a333ba3 100644
--- a/docs/stackit_postgresflex_instance_create.md
+++ b/docs/stackit_postgresflex_instance_create.md
@@ -46,6 +46,7 @@ stackit postgresflex instance create [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_instance_delete.md b/docs/stackit_postgresflex_instance_delete.md
index 0f864709e..17992e627 100644
--- a/docs/stackit_postgresflex_instance_delete.md
+++ b/docs/stackit_postgresflex_instance_delete.md
@@ -36,6 +36,7 @@ stackit postgresflex instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_instance_describe.md b/docs/stackit_postgresflex_instance_describe.md
index 957dd5450..629ffbc9b 100644
--- a/docs/stackit_postgresflex_instance_describe.md
+++ b/docs/stackit_postgresflex_instance_describe.md
@@ -33,6 +33,7 @@ stackit postgresflex instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_instance_list.md b/docs/stackit_postgresflex_instance_list.md
index a05845ba0..9d452376b 100644
--- a/docs/stackit_postgresflex_instance_list.md
+++ b/docs/stackit_postgresflex_instance_list.md
@@ -37,6 +37,7 @@ stackit postgresflex instance list [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_instance_update.md b/docs/stackit_postgresflex_instance_update.md
index 75a10e519..844e7d22a 100644
--- a/docs/stackit_postgresflex_instance_update.md
+++ b/docs/stackit_postgresflex_instance_update.md
@@ -43,6 +43,7 @@ stackit postgresflex instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_options.md b/docs/stackit_postgresflex_options.md
index 0638b2ec4..7fa8ee2bd 100644
--- a/docs/stackit_postgresflex_options.md
+++ b/docs/stackit_postgresflex_options.md
@@ -41,6 +41,7 @@ stackit postgresflex options [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_user.md b/docs/stackit_postgresflex_user.md
index 2e0d97ffc..b1793c663 100644
--- a/docs/stackit_postgresflex_user.md
+++ b/docs/stackit_postgresflex_user.md
@@ -23,6 +23,7 @@ stackit postgresflex user [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_user_create.md b/docs/stackit_postgresflex_user_create.md
index 335038430..fb66d84c3 100644
--- a/docs/stackit_postgresflex_user_create.md
+++ b/docs/stackit_postgresflex_user_create.md
@@ -39,6 +39,7 @@ stackit postgresflex user create [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_user_delete.md b/docs/stackit_postgresflex_user_delete.md
index f85225ede..2bdd099d7 100644
--- a/docs/stackit_postgresflex_user_delete.md
+++ b/docs/stackit_postgresflex_user_delete.md
@@ -33,6 +33,7 @@ stackit postgresflex user delete USER_ID [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_user_describe.md b/docs/stackit_postgresflex_user_describe.md
index 40e5a5bd9..365b6764b 100644
--- a/docs/stackit_postgresflex_user_describe.md
+++ b/docs/stackit_postgresflex_user_describe.md
@@ -36,6 +36,7 @@ stackit postgresflex user describe USER_ID [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_user_list.md b/docs/stackit_postgresflex_user_list.md
index 9db37967d..986fc8567 100644
--- a/docs/stackit_postgresflex_user_list.md
+++ b/docs/stackit_postgresflex_user_list.md
@@ -38,6 +38,7 @@ stackit postgresflex user list [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_user_reset-password.md b/docs/stackit_postgresflex_user_reset-password.md
index 857313808..42216c5e8 100644
--- a/docs/stackit_postgresflex_user_reset-password.md
+++ b/docs/stackit_postgresflex_user_reset-password.md
@@ -32,6 +32,7 @@ stackit postgresflex user reset-password USER_ID [flags]
--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")
```
diff --git a/docs/stackit_postgresflex_user_update.md b/docs/stackit_postgresflex_user_update.md
index f0a0e5250..d76b18447 100644
--- a/docs/stackit_postgresflex_user_update.md
+++ b/docs/stackit_postgresflex_user_update.md
@@ -32,6 +32,7 @@ stackit postgresflex user update USER_ID [flags]
--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")
```
diff --git a/docs/stackit_project.md b/docs/stackit_project.md
index 7f002da38..cd2eb8f46 100644
--- a/docs/stackit_project.md
+++ b/docs/stackit_project.md
@@ -24,6 +24,7 @@ stackit project [flags]
--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")
```
diff --git a/docs/stackit_project_create.md b/docs/stackit_project_create.md
index ae6e22723..9cc565f2b 100644
--- a/docs/stackit_project_create.md
+++ b/docs/stackit_project_create.md
@@ -5,6 +5,11 @@ Creates a STACKIT project
### Synopsis
Creates a STACKIT project.
+You can associate a project with a STACKIT Network Area (SNA) by providing the ID of the SNA.
+The STACKIT Network Area (SNA) allows projects within an organization to be connected to each other on a network level.
+This makes it possible to connect various resources of the projects within an SNA and also simplifies the connection with on-prem environments (hybrid cloud).
+The network type can no longer be changed after the project has been created. If you require a different network type, you must create a new project.
+
```
stackit project create [flags]
@@ -18,15 +23,19 @@ stackit project create [flags]
Create a STACKIT project with a set of labels
$ stackit project create --parent-id xxxx --name my-project --label key=value --label foo=bar
+
+ Create a STACKIT project with a network area
+ $ stackit project create --parent-id xxxx --name my-project --network-area-id yyyy
```
### Options
```
- -h, --help Help for "stackit project create"
- --label stringToString Labels are key-value string pairs which can be attached to a project. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels (default [])
- --name string Project name
- --parent-id string Parent resource identifier. Both container ID (user-friendly) and UUID are supported
+ -h, --help Help for "stackit project create"
+ --label stringToString Labels are key-value string pairs which can be attached to a project. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels (default [])
+ --name string Project name
+ --network-area-id string ID of a STACKIT Network Area (SNA) to associate with the project.
+ --parent-id string Parent resource identifier. Both container ID (user-friendly) and UUID are supported
```
### Options inherited from parent commands
@@ -36,6 +45,7 @@ stackit project create [flags]
--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")
```
diff --git a/docs/stackit_project_delete.md b/docs/stackit_project_delete.md
index 66b60ed00..b6eb31164 100644
--- a/docs/stackit_project_delete.md
+++ b/docs/stackit_project_delete.md
@@ -33,6 +33,7 @@ stackit project delete [flags]
--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")
```
diff --git a/docs/stackit_project_describe.md b/docs/stackit_project_describe.md
index a3976ece6..6afced7fc 100644
--- a/docs/stackit_project_describe.md
+++ b/docs/stackit_project_describe.md
@@ -37,6 +37,7 @@ stackit project describe [flags]
--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")
```
diff --git a/docs/stackit_project_list.md b/docs/stackit_project_list.md
index 28aa2693b..f4b22b007 100644
--- a/docs/stackit_project_list.md
+++ b/docs/stackit_project_list.md
@@ -45,6 +45,7 @@ stackit project list [flags]
--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")
```
diff --git a/docs/stackit_project_member.md b/docs/stackit_project_member.md
index c2021638a..445143fe2 100644
--- a/docs/stackit_project_member.md
+++ b/docs/stackit_project_member.md
@@ -23,6 +23,7 @@ stackit project member [flags]
--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")
```
diff --git a/docs/stackit_project_member_add.md b/docs/stackit_project_member_add.md
index 105676243..d3fc54513 100644
--- a/docs/stackit_project_member_add.md
+++ b/docs/stackit_project_member_add.md
@@ -35,6 +35,7 @@ stackit project member add SUBJECT [flags]
--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")
```
diff --git a/docs/stackit_project_member_list.md b/docs/stackit_project_member_list.md
index c27436336..18d7874d8 100644
--- a/docs/stackit_project_member_list.md
+++ b/docs/stackit_project_member_list.md
@@ -39,6 +39,7 @@ stackit project member list [flags]
--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")
```
diff --git a/docs/stackit_project_member_remove.md b/docs/stackit_project_member_remove.md
index 9c9e46ffe..908e44523 100644
--- a/docs/stackit_project_member_remove.md
+++ b/docs/stackit_project_member_remove.md
@@ -37,6 +37,7 @@ stackit project member remove SUBJECT [flags]
--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")
```
diff --git a/docs/stackit_project_role.md b/docs/stackit_project_role.md
index b95577866..d02d2512d 100644
--- a/docs/stackit_project_role.md
+++ b/docs/stackit_project_role.md
@@ -23,6 +23,7 @@ stackit project role [flags]
--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")
```
diff --git a/docs/stackit_project_role_list.md b/docs/stackit_project_role_list.md
index 6744c4c8c..90c54d0b1 100644
--- a/docs/stackit_project_role_list.md
+++ b/docs/stackit_project_role_list.md
@@ -37,6 +37,7 @@ stackit project role list [flags]
--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")
```
diff --git a/docs/stackit_project_update.md b/docs/stackit_project_update.md
index b057276be..9c64e8f6a 100644
--- a/docs/stackit_project_update.md
+++ b/docs/stackit_project_update.md
@@ -39,6 +39,7 @@ stackit project update [flags]
--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")
```
diff --git a/docs/stackit_public-ip.md b/docs/stackit_public-ip.md
new file mode 100644
index 000000000..d5dcafd53
--- /dev/null
+++ b/docs/stackit_public-ip.md
@@ -0,0 +1,41 @@
+## stackit public-ip
+
+Provides functionality for public IPs
+
+### Synopsis
+
+Provides functionality for public IPs.
+
+```
+stackit public-ip [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit public-ip"
+```
+
+### 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 public-ip associate](./stackit_public-ip_associate.md) - Associates a Public IP with a network interface or a virtual IP
+* [stackit public-ip create](./stackit_public-ip_create.md) - Creates a Public IP
+* [stackit public-ip delete](./stackit_public-ip_delete.md) - Deletes a Public IP
+* [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_associate.md b/docs/stackit_public-ip_associate.md
new file mode 100644
index 000000000..484eabaf8
--- /dev/null
+++ b/docs/stackit_public-ip_associate.md
@@ -0,0 +1,41 @@
+## stackit public-ip associate
+
+Associates a Public IP with a network interface or a virtual IP
+
+### Synopsis
+
+Associates a Public IP with a network interface or a virtual IP.
+
+```
+stackit public-ip associate PUBLIC_IP_ID [flags]
+```
+
+### Examples
+
+```
+ Associate public IP with ID "xxx" to a resource (network interface or virtual IP) with ID "yyy"
+ $ stackit public-ip associate xxx --associated-resource-id yyy
+```
+
+### Options
+
+```
+ --associated-resource-id string Associates the public IP with a network interface or virtual IP (ID)
+ -h, --help Help for "stackit public-ip associate"
+```
+
+### 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
+
diff --git a/docs/stackit_public-ip_create.md b/docs/stackit_public-ip_create.md
new file mode 100644
index 000000000..9ea017de9
--- /dev/null
+++ b/docs/stackit_public-ip_create.md
@@ -0,0 +1,48 @@
+## stackit public-ip create
+
+Creates a Public IP
+
+### Synopsis
+
+Creates a Public IP.
+
+```
+stackit public-ip create [flags]
+```
+
+### Examples
+
+```
+ Create a public IP
+ $ stackit public-ip create
+
+ Create a public IP with associated resource ID "xxx"
+ $ stackit public-ip create --associated-resource-id xxx
+
+ Create a public IP with associated resource ID "xxx" and labels
+ $ stackit public-ip create --associated-resource-id xxx --labels key=value,foo=bar
+```
+
+### Options
+
+```
+ --associated-resource-id string Associates the public IP with a network interface or virtual IP (ID)
+ -h, --help Help for "stackit public-ip create"
+ --labels stringToString Labels are key-value string pairs which can be attached to a public IP. E.g. '--labels key1=value1,key2=value2,...' (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 public-ip](./stackit_public-ip.md) - Provides functionality for public IPs
+
diff --git a/docs/stackit_public-ip_delete.md b/docs/stackit_public-ip_delete.md
new file mode 100644
index 000000000..d5d7c46e2
--- /dev/null
+++ b/docs/stackit_public-ip_delete.md
@@ -0,0 +1,42 @@
+## stackit public-ip delete
+
+Deletes a Public IP
+
+### Synopsis
+
+Deletes a Public IP.
+If the public IP is still in use, the deletion will fail
+
+
+```
+stackit public-ip delete PUBLIC_IP_ID [flags]
+```
+
+### Examples
+
+```
+ Delete public IP with ID "xxx"
+ $ stackit public-ip delete xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit public-ip 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 public-ip](./stackit_public-ip.md) - Provides functionality for public IPs
+
diff --git a/docs/stackit_public-ip_describe.md b/docs/stackit_public-ip_describe.md
new file mode 100644
index 000000000..811d7c4d7
--- /dev/null
+++ b/docs/stackit_public-ip_describe.md
@@ -0,0 +1,43 @@
+## stackit public-ip describe
+
+Shows details of a Public IP
+
+### Synopsis
+
+Shows details of a Public IP.
+
+```
+stackit public-ip describe PUBLIC_IP_ID [flags]
+```
+
+### Examples
+
+```
+ Show details of a public IP with ID "xxx"
+ $ stackit public-ip describe xxx
+
+ Show details of a public IP with ID "xxx" in JSON format
+ $ stackit public-ip describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit public-ip 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 public-ip](./stackit_public-ip.md) - Provides functionality for public IPs
+
diff --git a/docs/stackit_public-ip_disassociate.md b/docs/stackit_public-ip_disassociate.md
new file mode 100644
index 000000000..992ca2044
--- /dev/null
+++ b/docs/stackit_public-ip_disassociate.md
@@ -0,0 +1,40 @@
+## stackit public-ip disassociate
+
+Disassociates a Public IP from a network interface or a virtual IP
+
+### Synopsis
+
+Disassociates a Public IP from a network interface or a virtual IP.
+
+```
+stackit public-ip disassociate PUBLIC_IP_ID [flags]
+```
+
+### Examples
+
+```
+ Disassociate public IP with ID "xxx" from a resource (network interface or virtual IP)
+ $ stackit public-ip disassociate xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit public-ip disassociate"
+```
+
+### 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
+
diff --git a/docs/stackit_public-ip_list.md b/docs/stackit_public-ip_list.md
new file mode 100644
index 000000000..513394fa9
--- /dev/null
+++ b/docs/stackit_public-ip_list.md
@@ -0,0 +1,51 @@
+## stackit public-ip list
+
+Lists all Public IPs of a project
+
+### Synopsis
+
+Lists all Public IPs of a project.
+
+```
+stackit public-ip list [flags]
+```
+
+### Examples
+
+```
+ Lists all public IPs
+ $ stackit public-ip list
+
+ Lists all public IPs which contains the label xxx
+ $ stackit public-ip list --label-selector xxx
+
+ Lists all public IPs in JSON format
+ $ stackit public-ip list --output-format json
+
+ Lists up to 10 public IPs
+ $ stackit public-ip list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit public-ip list"
+ --label-selector string Filter by label
+ --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](./stackit_public-ip.md) - Provides functionality for public IPs
+
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_public-ip_update.md b/docs/stackit_public-ip_update.md
new file mode 100644
index 000000000..96a625987
--- /dev/null
+++ b/docs/stackit_public-ip_update.md
@@ -0,0 +1,44 @@
+## stackit public-ip update
+
+Updates a Public IP
+
+### Synopsis
+
+Updates a Public IP.
+
+```
+stackit public-ip update PUBLIC_IP_ID [flags]
+```
+
+### Examples
+
+```
+ Update public IP with ID "xxx"
+ $ stackit public-ip update xxx
+
+ Update public IP with ID "xxx" with new labels
+ $ stackit public-ip update xxx --labels key=value,foo=bar
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit public-ip update"
+ --labels stringToString Labels are key-value string pairs which can be attached to a public IP. E.g. '--labels key1=value1,key2=value2,...' (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 public-ip](./stackit_public-ip.md) - Provides functionality for public IPs
+
diff --git a/docs/stackit_quota.md b/docs/stackit_quota.md
new file mode 100644
index 000000000..074b95cbc
--- /dev/null
+++ b/docs/stackit_quota.md
@@ -0,0 +1,34 @@
+## stackit quota
+
+Manage server quotas
+
+### Synopsis
+
+Manage the lifecycle of server quotas.
+
+```
+stackit quota [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit quota"
+```
+
+### 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 quota list](./stackit_quota_list.md) - Lists quotas
+
diff --git a/docs/stackit_quota_list.md b/docs/stackit_quota_list.md
new file mode 100644
index 000000000..f68113391
--- /dev/null
+++ b/docs/stackit_quota_list.md
@@ -0,0 +1,40 @@
+## stackit quota list
+
+Lists quotas
+
+### Synopsis
+
+Lists project quotas.
+
+```
+stackit quota list [flags]
+```
+
+### Examples
+
+```
+ List available quotas
+ $ stackit quota list
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit quota 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 quota](./stackit_quota.md) - Manage server quotas
+
diff --git a/docs/stackit_rabbitmq.md b/docs/stackit_rabbitmq.md
index e3060985c..2e6779ccc 100644
--- a/docs/stackit_rabbitmq.md
+++ b/docs/stackit_rabbitmq.md
@@ -23,6 +23,7 @@ stackit rabbitmq [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_credentials.md b/docs/stackit_rabbitmq_credentials.md
index 96968f4b3..debec8700 100644
--- a/docs/stackit_rabbitmq_credentials.md
+++ b/docs/stackit_rabbitmq_credentials.md
@@ -23,6 +23,7 @@ stackit rabbitmq credentials [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_credentials_create.md b/docs/stackit_rabbitmq_credentials_create.md
index 5221b186e..98f1c8e4c 100644
--- a/docs/stackit_rabbitmq_credentials_create.md
+++ b/docs/stackit_rabbitmq_credentials_create.md
@@ -35,6 +35,7 @@ stackit rabbitmq credentials create [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_credentials_delete.md b/docs/stackit_rabbitmq_credentials_delete.md
index ff8c1e207..9e9cae18e 100644
--- a/docs/stackit_rabbitmq_credentials_delete.md
+++ b/docs/stackit_rabbitmq_credentials_delete.md
@@ -31,6 +31,7 @@ stackit rabbitmq credentials delete CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_credentials_describe.md b/docs/stackit_rabbitmq_credentials_describe.md
index 48fb1b2ab..bd1f9fad3 100644
--- a/docs/stackit_rabbitmq_credentials_describe.md
+++ b/docs/stackit_rabbitmq_credentials_describe.md
@@ -34,6 +34,7 @@ stackit rabbitmq credentials describe CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_credentials_list.md b/docs/stackit_rabbitmq_credentials_list.md
index 105de4d9f..8fe0b14f6 100644
--- a/docs/stackit_rabbitmq_credentials_list.md
+++ b/docs/stackit_rabbitmq_credentials_list.md
@@ -38,6 +38,7 @@ stackit rabbitmq credentials list [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_instance.md b/docs/stackit_rabbitmq_instance.md
index e417d7664..0c6dd7af3 100644
--- a/docs/stackit_rabbitmq_instance.md
+++ b/docs/stackit_rabbitmq_instance.md
@@ -23,6 +23,7 @@ stackit rabbitmq instance [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_instance_create.md b/docs/stackit_rabbitmq_instance_create.md
index bc14709c9..8b00f0fcc 100644
--- a/docs/stackit_rabbitmq_instance_create.md
+++ b/docs/stackit_rabbitmq_instance_create.md
@@ -48,6 +48,7 @@ stackit rabbitmq instance create [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_instance_delete.md b/docs/stackit_rabbitmq_instance_delete.md
index f216d6cd3..4a078fc5e 100644
--- a/docs/stackit_rabbitmq_instance_delete.md
+++ b/docs/stackit_rabbitmq_instance_delete.md
@@ -30,6 +30,7 @@ stackit rabbitmq instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_instance_describe.md b/docs/stackit_rabbitmq_instance_describe.md
index 1aa75f6ef..e2cb8cb74 100644
--- a/docs/stackit_rabbitmq_instance_describe.md
+++ b/docs/stackit_rabbitmq_instance_describe.md
@@ -33,6 +33,7 @@ stackit rabbitmq instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_instance_list.md b/docs/stackit_rabbitmq_instance_list.md
index 5fc3d72f1..196a8f275 100644
--- a/docs/stackit_rabbitmq_instance_list.md
+++ b/docs/stackit_rabbitmq_instance_list.md
@@ -37,6 +37,7 @@ stackit rabbitmq instance list [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_instance_update.md b/docs/stackit_rabbitmq_instance_update.md
index 6976587dc..985781786 100644
--- a/docs/stackit_rabbitmq_instance_update.md
+++ b/docs/stackit_rabbitmq_instance_update.md
@@ -44,6 +44,7 @@ stackit rabbitmq instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_rabbitmq_plans.md b/docs/stackit_rabbitmq_plans.md
index ebd2d6652..fe33723a0 100644
--- a/docs/stackit_rabbitmq_plans.md
+++ b/docs/stackit_rabbitmq_plans.md
@@ -37,6 +37,7 @@ stackit rabbitmq plans [flags]
--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")
```
diff --git a/docs/stackit_redis.md b/docs/stackit_redis.md
index 16beda8f8..b371d2389 100644
--- a/docs/stackit_redis.md
+++ b/docs/stackit_redis.md
@@ -23,6 +23,7 @@ stackit redis [flags]
--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")
```
diff --git a/docs/stackit_redis_credentials.md b/docs/stackit_redis_credentials.md
index 37c2a269d..323147b4b 100644
--- a/docs/stackit_redis_credentials.md
+++ b/docs/stackit_redis_credentials.md
@@ -23,6 +23,7 @@ stackit redis credentials [flags]
--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")
```
diff --git a/docs/stackit_redis_credentials_create.md b/docs/stackit_redis_credentials_create.md
index 18fa56dad..444f9ee1e 100644
--- a/docs/stackit_redis_credentials_create.md
+++ b/docs/stackit_redis_credentials_create.md
@@ -35,6 +35,7 @@ stackit redis credentials create [flags]
--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")
```
diff --git a/docs/stackit_redis_credentials_delete.md b/docs/stackit_redis_credentials_delete.md
index a975a0a71..2ff4795cf 100644
--- a/docs/stackit_redis_credentials_delete.md
+++ b/docs/stackit_redis_credentials_delete.md
@@ -31,6 +31,7 @@ stackit redis credentials delete CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_redis_credentials_describe.md b/docs/stackit_redis_credentials_describe.md
index 274c06563..8c3937344 100644
--- a/docs/stackit_redis_credentials_describe.md
+++ b/docs/stackit_redis_credentials_describe.md
@@ -34,6 +34,7 @@ stackit redis credentials describe CREDENTIALS_ID [flags]
--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")
```
diff --git a/docs/stackit_redis_credentials_list.md b/docs/stackit_redis_credentials_list.md
index 421d09924..14c162c0b 100644
--- a/docs/stackit_redis_credentials_list.md
+++ b/docs/stackit_redis_credentials_list.md
@@ -38,6 +38,7 @@ stackit redis credentials list [flags]
--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")
```
diff --git a/docs/stackit_redis_instance.md b/docs/stackit_redis_instance.md
index d5c956ee8..009415f9b 100644
--- a/docs/stackit_redis_instance.md
+++ b/docs/stackit_redis_instance.md
@@ -23,6 +23,7 @@ stackit redis instance [flags]
--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")
```
diff --git a/docs/stackit_redis_instance_create.md b/docs/stackit_redis_instance_create.md
index 92f3892cb..004741d48 100644
--- a/docs/stackit_redis_instance_create.md
+++ b/docs/stackit_redis_instance_create.md
@@ -47,6 +47,7 @@ stackit redis instance create [flags]
--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")
```
diff --git a/docs/stackit_redis_instance_delete.md b/docs/stackit_redis_instance_delete.md
index 4ad2da13b..2508659ee 100644
--- a/docs/stackit_redis_instance_delete.md
+++ b/docs/stackit_redis_instance_delete.md
@@ -30,6 +30,7 @@ stackit redis instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_redis_instance_describe.md b/docs/stackit_redis_instance_describe.md
index 35c839779..4f7632ca3 100644
--- a/docs/stackit_redis_instance_describe.md
+++ b/docs/stackit_redis_instance_describe.md
@@ -33,6 +33,7 @@ stackit redis instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_redis_instance_list.md b/docs/stackit_redis_instance_list.md
index e32b8763e..3c97031ac 100644
--- a/docs/stackit_redis_instance_list.md
+++ b/docs/stackit_redis_instance_list.md
@@ -37,6 +37,7 @@ stackit redis instance list [flags]
--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")
```
diff --git a/docs/stackit_redis_instance_update.md b/docs/stackit_redis_instance_update.md
index 2971e74b6..801622ed7 100644
--- a/docs/stackit_redis_instance_update.md
+++ b/docs/stackit_redis_instance_update.md
@@ -43,6 +43,7 @@ stackit redis instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_redis_plans.md b/docs/stackit_redis_plans.md
index 5cce98b9a..2dc40245a 100644
--- a/docs/stackit_redis_plans.md
+++ b/docs/stackit_redis_plans.md
@@ -37,6 +37,7 @@ stackit redis plans [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager.md b/docs/stackit_secrets-manager.md
index 59e7a99d6..84bb95a52 100644
--- a/docs/stackit_secrets-manager.md
+++ b/docs/stackit_secrets-manager.md
@@ -23,6 +23,7 @@ stackit secrets-manager [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_instance.md b/docs/stackit_secrets-manager_instance.md
index d7857e067..db579c80c 100644
--- a/docs/stackit_secrets-manager_instance.md
+++ b/docs/stackit_secrets-manager_instance.md
@@ -23,6 +23,7 @@ stackit secrets-manager instance [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_instance_create.md b/docs/stackit_secrets-manager_instance_create.md
index d41249ca5..379de7785 100644
--- a/docs/stackit_secrets-manager_instance_create.md
+++ b/docs/stackit_secrets-manager_instance_create.md
@@ -35,6 +35,7 @@ stackit secrets-manager instance create [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_instance_delete.md b/docs/stackit_secrets-manager_instance_delete.md
index 057e9b1ba..0a8b18c6b 100644
--- a/docs/stackit_secrets-manager_instance_delete.md
+++ b/docs/stackit_secrets-manager_instance_delete.md
@@ -30,6 +30,7 @@ stackit secrets-manager instance delete INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_instance_describe.md b/docs/stackit_secrets-manager_instance_describe.md
index d0f6756f5..d2695dc76 100644
--- a/docs/stackit_secrets-manager_instance_describe.md
+++ b/docs/stackit_secrets-manager_instance_describe.md
@@ -33,6 +33,7 @@ stackit secrets-manager instance describe INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_instance_list.md b/docs/stackit_secrets-manager_instance_list.md
index 46cbcd4cd..742dd9000 100644
--- a/docs/stackit_secrets-manager_instance_list.md
+++ b/docs/stackit_secrets-manager_instance_list.md
@@ -37,6 +37,7 @@ stackit secrets-manager instance list [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_instance_update.md b/docs/stackit_secrets-manager_instance_update.md
index 54f4b877c..cf40d3c1a 100644
--- a/docs/stackit_secrets-manager_instance_update.md
+++ b/docs/stackit_secrets-manager_instance_update.md
@@ -31,6 +31,7 @@ stackit secrets-manager instance update INSTANCE_ID [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_user.md b/docs/stackit_secrets-manager_user.md
index 65899e83f..b6ba33cae 100644
--- a/docs/stackit_secrets-manager_user.md
+++ b/docs/stackit_secrets-manager_user.md
@@ -23,6 +23,7 @@ stackit secrets-manager user [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_user_create.md b/docs/stackit_secrets-manager_user_create.md
index 043914508..34f83c324 100644
--- a/docs/stackit_secrets-manager_user_create.md
+++ b/docs/stackit_secrets-manager_user_create.md
@@ -38,6 +38,7 @@ stackit secrets-manager user create [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_user_delete.md b/docs/stackit_secrets-manager_user_delete.md
index 4d0c30091..eb2a28000 100644
--- a/docs/stackit_secrets-manager_user_delete.md
+++ b/docs/stackit_secrets-manager_user_delete.md
@@ -32,6 +32,7 @@ stackit secrets-manager user delete USER_ID [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_user_describe.md b/docs/stackit_secrets-manager_user_describe.md
index 0d4d77739..56ca9f25b 100644
--- a/docs/stackit_secrets-manager_user_describe.md
+++ b/docs/stackit_secrets-manager_user_describe.md
@@ -34,6 +34,7 @@ stackit secrets-manager user describe USER_ID [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_user_list.md b/docs/stackit_secrets-manager_user_list.md
index cb500f260..b610591be 100644
--- a/docs/stackit_secrets-manager_user_list.md
+++ b/docs/stackit_secrets-manager_user_list.md
@@ -38,6 +38,7 @@ stackit secrets-manager user list [flags]
--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")
```
diff --git a/docs/stackit_secrets-manager_user_update.md b/docs/stackit_secrets-manager_user_update.md
index a6388f6d1..c181eb96e 100644
--- a/docs/stackit_secrets-manager_user_update.md
+++ b/docs/stackit_secrets-manager_user_update.md
@@ -36,6 +36,7 @@ stackit secrets-manager user update USER_ID [flags]
--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")
```
diff --git a/docs/stackit_security-group.md b/docs/stackit_security-group.md
new file mode 100644
index 000000000..949333c96
--- /dev/null
+++ b/docs/stackit_security-group.md
@@ -0,0 +1,39 @@
+## stackit security-group
+
+Manage security groups
+
+### Synopsis
+
+Manage the lifecycle of security groups and rules.
+
+```
+stackit security-group [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit 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](./stackit.md) - Manage STACKIT resources using the command line
+* [stackit security-group create](./stackit_security-group_create.md) - Creates security groups
+* [stackit security-group delete](./stackit_security-group_delete.md) - Deletes a security group
+* [stackit security-group describe](./stackit_security-group_describe.md) - Describes security groups
+* [stackit security-group list](./stackit_security-group_list.md) - Lists security groups
+* [stackit security-group rule](./stackit_security-group_rule.md) - Provides functionality for security group rules
+* [stackit security-group update](./stackit_security-group_update.md) - Updates a security group
+
diff --git a/docs/stackit_security-group_create.md b/docs/stackit_security-group_create.md
new file mode 100644
index 000000000..c63370118
--- /dev/null
+++ b/docs/stackit_security-group_create.md
@@ -0,0 +1,47 @@
+## stackit security-group create
+
+Creates security groups
+
+### Synopsis
+
+Creates security groups.
+
+```
+stackit security-group create [flags]
+```
+
+### Examples
+
+```
+ Create a named group
+ $ stackit security-group create --name my-new-group
+
+ Create a named group with labels
+ $ stackit security-group create --name my-new-group --labels label1=value1,label2=value2
+```
+
+### Options
+
+```
+ --description string An optional description of the security group.
+ -h, --help Help for "stackit security-group create"
+ --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ --name string The name of the security group.
+ --stateful Create a stateful or a stateless 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 security-group](./stackit_security-group.md) - Manage security groups
+
diff --git a/docs/stackit_security-group_delete.md b/docs/stackit_security-group_delete.md
new file mode 100644
index 000000000..6402e0bd2
--- /dev/null
+++ b/docs/stackit_security-group_delete.md
@@ -0,0 +1,40 @@
+## stackit security-group delete
+
+Deletes a security group
+
+### Synopsis
+
+Deletes a security group by its internal ID.
+
+```
+stackit security-group delete GROUP_ID [flags]
+```
+
+### Examples
+
+```
+ Delete a named group with ID "xxx"
+ $ stackit security-group delete xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit security-group 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 security-group](./stackit_security-group.md) - Manage security groups
+
diff --git a/docs/stackit_security-group_describe.md b/docs/stackit_security-group_describe.md
new file mode 100644
index 000000000..2a29d26fc
--- /dev/null
+++ b/docs/stackit_security-group_describe.md
@@ -0,0 +1,40 @@
+## stackit security-group describe
+
+Describes security groups
+
+### Synopsis
+
+Describes security groups by its internal ID.
+
+```
+stackit security-group describe GROUP_ID [flags]
+```
+
+### Examples
+
+```
+ Describe group "xxx"
+ $ stackit security-group describe xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit security-group 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 security-group](./stackit_security-group.md) - Manage security groups
+
diff --git a/docs/stackit_security-group_list.md b/docs/stackit_security-group_list.md
new file mode 100644
index 000000000..990f01364
--- /dev/null
+++ b/docs/stackit_security-group_list.md
@@ -0,0 +1,44 @@
+## stackit security-group list
+
+Lists security groups
+
+### Synopsis
+
+Lists security groups by its internal ID.
+
+```
+stackit security-group list [flags]
+```
+
+### Examples
+
+```
+ List all groups
+ $ stackit security-group list
+
+ List groups with labels
+ $ stackit security-group list --label-selector label1=value1,label2=value2
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit security-group list"
+ --label-selector string Filter by label
+```
+
+### 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 security-group](./stackit_security-group.md) - Manage security groups
+
diff --git a/docs/stackit_security-group_rule.md b/docs/stackit_security-group_rule.md
new file mode 100644
index 000000000..558abe544
--- /dev/null
+++ b/docs/stackit_security-group_rule.md
@@ -0,0 +1,37 @@
+## stackit security-group rule
+
+Provides functionality for security group rules
+
+### Synopsis
+
+Provides functionality for security group rules.
+
+```
+stackit security-group rule [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit security-group rule"
+```
+
+### 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 security-group](./stackit_security-group.md) - Manage security groups
+* [stackit security-group rule create](./stackit_security-group_rule_create.md) - Creates a security group rule
+* [stackit security-group rule delete](./stackit_security-group_rule_delete.md) - Deletes a security group rule
+* [stackit security-group rule describe](./stackit_security-group_rule_describe.md) - Shows details of a security group rule
+* [stackit security-group rule list](./stackit_security-group_rule_list.md) - Lists all security group rules in a security group of a project
+
diff --git a/docs/stackit_security-group_rule_create.md b/docs/stackit_security-group_rule_create.md
new file mode 100644
index 000000000..0ad7a823b
--- /dev/null
+++ b/docs/stackit_security-group_rule_create.md
@@ -0,0 +1,61 @@
+## stackit security-group rule create
+
+Creates a security group rule
+
+### Synopsis
+
+Creates a security group rule.
+
+```
+stackit security-group rule create [flags]
+```
+
+### Examples
+
+```
+ Create a security group rule for security group with ID "xxx" with direction "ingress"
+ $ stackit security-group rule create --security-group-id xxx --direction ingress
+
+ Create a security group rule for security group with ID "xxx" with direction "egress", protocol "icmp" and icmp parameters
+ $ stackit security-group rule create --security-group-id xxx --direction egress --protocol-name icmp --icmp-parameter-code 0 --icmp-parameter-type 8
+
+ Create a security group rule for security group with ID "xxx" with direction "ingress", protocol "tcp" and port range values
+ $ stackit security-group rule create --security-group-id xxx --direction ingress --protocol-name tcp --port-range-max 24 --port-range-min 22
+
+ Create a security group rule for security group with ID "xxx" with direction "ingress" and protocol number 1
+ $ stackit security-group rule create --security-group-id xxx --direction ingress --protocol-number 1
+```
+
+### Options
+
+```
+ --description string The rule description
+ --direction string The direction of the traffic which the rule should match. The possible values are: "ingress", "egress"
+ --ether-type string The ethertype which the rule should match
+ -h, --help Help for "stackit security-group rule create"
+ --icmp-parameter-code int ICMP code. Can be set if the protocol is ICMP
+ --icmp-parameter-type int ICMP type. Can be set if the protocol is ICMP
+ --ip-range string The remote IP range which the rule should match
+ --port-range-max int The maximum port number. Should be greater or equal to the minimum. This should only be provided if the protocol is not ICMP
+ --port-range-min int The minimum port number. Should be less or equal to the maximum. This should only be provided if the protocol is not ICMP
+ --protocol-name string The protocol name which the rule should match. If a protocol is to be defined, either "protocol-name" or "protocol-number" must be provided
+ --protocol-number int The protocol number which the rule should match. If a protocol is to be defined, either "protocol-name" or "protocol-number" must be provided
+ --remote-security-group-id string The remote security group which the rule should match
+ --security-group-id string The security group 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 security-group rule](./stackit_security-group_rule.md) - Provides functionality for security group rules
+
diff --git a/docs/stackit_security-group_rule_delete.md b/docs/stackit_security-group_rule_delete.md
new file mode 100644
index 000000000..003912835
--- /dev/null
+++ b/docs/stackit_security-group_rule_delete.md
@@ -0,0 +1,43 @@
+## stackit security-group rule delete
+
+Deletes a security group rule
+
+### Synopsis
+
+Deletes a security group rule.
+If the security group rule is still in use, the deletion will fail
+
+
+```
+stackit security-group rule delete SECURITY_GROUP_RULE_ID [flags]
+```
+
+### Examples
+
+```
+ Delete security group rule with ID "xxx" in security group with ID "yyy"
+ $ stackit security-group rule delete xxx --security-group-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit security-group rule delete"
+ --security-group-id string The security group 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 security-group rule](./stackit_security-group_rule.md) - Provides functionality for security group rules
+
diff --git a/docs/stackit_security-group_rule_describe.md b/docs/stackit_security-group_rule_describe.md
new file mode 100644
index 000000000..66579d57e
--- /dev/null
+++ b/docs/stackit_security-group_rule_describe.md
@@ -0,0 +1,44 @@
+## stackit security-group rule describe
+
+Shows details of a security group rule
+
+### Synopsis
+
+Shows details of a security group rule.
+
+```
+stackit security-group rule describe SECURITY_GROUP_RULE_ID [flags]
+```
+
+### Examples
+
+```
+ Show details of a security group rule with ID "xxx" in security group with ID "yyy"
+ $ stackit security-group rule describe xxx --security-group-id yyy
+
+ Show details of a security group rule with ID "xxx" in security group with ID "yyy" in JSON format
+ $ stackit security-group rule describe xxx --security-group-id yyy --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit security-group rule describe"
+ --security-group-id string The security group 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 security-group rule](./stackit_security-group_rule.md) - Provides functionality for security group rules
+
diff --git a/docs/stackit_security-group_rule_list.md b/docs/stackit_security-group_rule_list.md
new file mode 100644
index 000000000..c1aff833b
--- /dev/null
+++ b/docs/stackit_security-group_rule_list.md
@@ -0,0 +1,48 @@
+## stackit security-group rule list
+
+Lists all security group rules in a security group of a project
+
+### Synopsis
+
+Lists all security group rules in a security group of a project.
+
+```
+stackit security-group rule list [flags]
+```
+
+### Examples
+
+```
+ Lists all security group rules in security group with ID "xxx"
+ $ stackit security-group rule list --security-group-id xxx
+
+ Lists all security group rules in security group with ID "xxx" in JSON format
+ $ stackit security-group rule list --security-group-id xxx --output-format json
+
+ Lists up to 10 security group rules in security group with ID "xxx"
+ $ stackit security-group rule list --security-group-id xxx --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit security-group rule list"
+ --limit int Maximum number of entries to list
+ --security-group-id string The security group 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 security-group rule](./stackit_security-group_rule.md) - Provides functionality for security group rules
+
diff --git a/docs/stackit_security-group_update.md b/docs/stackit_security-group_update.md
new file mode 100644
index 000000000..4dd30f03b
--- /dev/null
+++ b/docs/stackit_security-group_update.md
@@ -0,0 +1,46 @@
+## stackit security-group update
+
+Updates a security group
+
+### Synopsis
+
+Updates a named security group
+
+```
+stackit security-group update GROUP_ID [flags]
+```
+
+### Examples
+
+```
+ Update the name of group "xxx"
+ $ stackit security-group update xxx --name my-new-name
+
+ Update the labels of group "xxx"
+ $ stackit security-group update xxx --labels label1=value1,label2=value2
+```
+
+### Options
+
+```
+ --description string An optional description of the security group.
+ -h, --help Help for "stackit security-group update"
+ --labels stringToString Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ --name string The name of the 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 security-group](./stackit_security-group.md) - Manage security groups
+
diff --git a/docs/stackit_server.md b/docs/stackit_server.md
new file mode 100644
index 000000000..83bf55541
--- /dev/null
+++ b/docs/stackit_server.md
@@ -0,0 +1,55 @@
+## stackit server
+
+Provides functionality for servers
+
+### Synopsis
+
+Provides functionality for servers.
+
+```
+stackit server [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server"
+```
+
+### 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 server backup](./stackit_server_backup.md) - Provides functionality for server backups
+* [stackit server command](./stackit_server_command.md) - Provides functionality for Server Command
+* [stackit server console](./stackit_server_console.md) - Gets a URL for server remote console
+* [stackit server create](./stackit_server_create.md) - Creates a server
+* [stackit server deallocate](./stackit_server_deallocate.md) - Deallocates an existing server
+* [stackit server delete](./stackit_server_delete.md) - Deletes a server
+* [stackit server describe](./stackit_server_describe.md) - Shows details of a server
+* [stackit server list](./stackit_server_list.md) - Lists all servers of a project
+* [stackit server log](./stackit_server_log.md) - Gets server console log
+* [stackit server machine-type](./stackit_server_machine-type.md) - Provides functionality for server machine types available inside a project
+* [stackit server network-interface](./stackit_server_network-interface.md) - Allows attaching/detaching network interfaces to servers
+* [stackit server os-update](./stackit_server_os-update.md) - Provides functionality for managed server updates
+* [stackit server public-ip](./stackit_server_public-ip.md) - Allows attaching/detaching public IPs to servers
+* [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 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
+* [stackit server unrescue](./stackit_server_unrescue.md) - Unrescues an existing server
+* [stackit server update](./stackit_server_update.md) - Updates a server
+* [stackit server volume](./stackit_server_volume.md) - Provides functionality for server volumes
+
diff --git a/docs/stackit_server_backup.md b/docs/stackit_server_backup.md
new file mode 100644
index 000000000..40ec81ed9
--- /dev/null
+++ b/docs/stackit_server_backup.md
@@ -0,0 +1,42 @@
+## stackit server backup
+
+Provides functionality for server backups
+
+### Synopsis
+
+Provides functionality for server backups.
+
+```
+stackit server backup [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server 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 server](./stackit_server.md) - Provides functionality for servers
+* [stackit server backup create](./stackit_server_backup_create.md) - Creates a Server Backup.
+* [stackit server backup delete](./stackit_server_backup_delete.md) - Deletes a Server Backup.
+* [stackit server backup describe](./stackit_server_backup_describe.md) - Shows details of a Server Backup
+* [stackit server backup disable](./stackit_server_backup_disable.md) - Disables Server Backup service
+* [stackit server backup enable](./stackit_server_backup_enable.md) - Enables Server Backup service
+* [stackit server backup list](./stackit_server_backup_list.md) - Lists all server backups
+* [stackit server backup restore](./stackit_server_backup_restore.md) - Restores a Server Backup.
+* [stackit server backup schedule](./stackit_server_backup_schedule.md) - Provides functionality for Server Backup Schedule
+* [stackit server backup volume-backup](./stackit_server_backup_volume-backup.md) - Provides functionality for Server Backup Volume Backups
+
diff --git a/docs/stackit_server_backup_create.md b/docs/stackit_server_backup_create.md
new file mode 100644
index 000000000..0d77984fb
--- /dev/null
+++ b/docs/stackit_server_backup_create.md
@@ -0,0 +1,47 @@
+## stackit server backup create
+
+Creates a Server Backup.
+
+### Synopsis
+
+Creates a Server Backup. Operation always is async.
+
+```
+stackit server backup create [flags]
+```
+
+### Examples
+
+```
+ Create a Server Backup with name "mybackup"
+ $ stackit server backup create --server-id xxx --name=mybackup
+
+ 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
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup create"
+ -b, --name string Backup name
+ -d, --retention-period int Backup retention period (in days) (default 14)
+ -s, --server-id string Server ID
+ -i, --volume-ids strings Backup volume IDs, as comma separated UUID values. (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 server backup](./stackit_server_backup.md) - Provides functionality for server backups
+
diff --git a/docs/stackit_server_backup_delete.md b/docs/stackit_server_backup_delete.md
new file mode 100644
index 000000000..96e1fca84
--- /dev/null
+++ b/docs/stackit_server_backup_delete.md
@@ -0,0 +1,41 @@
+## stackit server backup delete
+
+Deletes a Server Backup.
+
+### Synopsis
+
+Deletes a Server Backup. Operation always is async.
+
+```
+stackit server backup delete BACKUP_ID [flags]
+```
+
+### Examples
+
+```
+ Delete a Server Backup with ID "xxx" for server "zzz"
+ $ stackit server backup delete xxx --server-id=zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup delete"
+ -s, --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 backup](./stackit_server_backup.md) - Provides functionality for server backups
+
diff --git a/docs/stackit_server_backup_describe.md b/docs/stackit_server_backup_describe.md
new file mode 100644
index 000000000..008fc02ee
--- /dev/null
+++ b/docs/stackit_server_backup_describe.md
@@ -0,0 +1,44 @@
+## stackit server backup describe
+
+Shows details of a Server Backup
+
+### Synopsis
+
+Shows details of a Server Backup.
+
+```
+stackit server backup describe BACKUP_ID [flags]
+```
+
+### Examples
+
+```
+ Get details of a Server Backup with id "my-backup-id"
+ $ stackit server backup describe my-backup-id
+
+ Get details of a Server Backup with id "my-backup-id" in JSON format
+ $ stackit server backup describe my-backup-id --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup describe"
+ -s, --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 backup](./stackit_server_backup.md) - Provides functionality for server backups
+
diff --git a/docs/stackit_server_backup_disable.md b/docs/stackit_server_backup_disable.md
new file mode 100644
index 000000000..3a5d623d1
--- /dev/null
+++ b/docs/stackit_server_backup_disable.md
@@ -0,0 +1,41 @@
+## stackit server backup disable
+
+Disables Server Backup service
+
+### Synopsis
+
+Disables Server Backup service.
+
+```
+stackit server backup disable [flags]
+```
+
+### Examples
+
+```
+ Disable Server Backup functionality for your server.
+ $ stackit server backup disable --server-id=zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup disable"
+ -s, --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 backup](./stackit_server_backup.md) - Provides functionality for server backups
+
diff --git a/docs/stackit_server_backup_enable.md b/docs/stackit_server_backup_enable.md
new file mode 100644
index 000000000..e0268a57f
--- /dev/null
+++ b/docs/stackit_server_backup_enable.md
@@ -0,0 +1,41 @@
+## stackit server backup enable
+
+Enables Server Backup service
+
+### Synopsis
+
+Enables Server Backup service.
+
+```
+stackit server backup enable [flags]
+```
+
+### Examples
+
+```
+ Enable Server Backup functionality for your server
+ $ stackit server backup enable --server-id=zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup enable"
+ -s, --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 backup](./stackit_server_backup.md) - Provides functionality for server backups
+
diff --git a/docs/stackit_server_backup_list.md b/docs/stackit_server_backup_list.md
new file mode 100644
index 000000000..702d4917b
--- /dev/null
+++ b/docs/stackit_server_backup_list.md
@@ -0,0 +1,45 @@
+## stackit server backup list
+
+Lists all server backups
+
+### Synopsis
+
+Lists all server backups.
+
+```
+stackit server backup list [flags]
+```
+
+### Examples
+
+```
+ List all backups for a server with ID "xxx"
+ $ stackit server backup list --server-id xxx
+
+ List all backups for a server with ID "xxx" in JSON format
+ $ stackit server backup list --server-id xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup list"
+ --limit int Maximum number of entries to list
+ -s, --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 backup](./stackit_server_backup.md) - Provides functionality for server backups
+
diff --git a/docs/stackit_server_backup_restore.md b/docs/stackit_server_backup_restore.md
new file mode 100644
index 000000000..1b33b16f6
--- /dev/null
+++ b/docs/stackit_server_backup_restore.md
@@ -0,0 +1,46 @@
+## stackit server backup restore
+
+Restores a Server Backup.
+
+### Synopsis
+
+Restores a Server Backup. Operation always is async.
+
+```
+stackit server backup restore BACKUP_ID [flags]
+```
+
+### Examples
+
+```
+ Restore a Server Backup with ID "xxx" for server "zzz"
+ $ stackit server backup restore xxx --server-id=zzz
+
+ Restore a Server Backup with ID "xxx" for server "zzz" and start the server afterwards
+ $ stackit server backup restore xxx --server-id=zzz --start-server-after-restore
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup restore"
+ -s, --server-id string Server ID
+ -u, --start-server-after-restore Should the server start after the backup restoring.
+ -i, --volume-ids strings Backup volume IDs, as comma separated UUID values. (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 server backup](./stackit_server_backup.md) - Provides functionality for server backups
+
diff --git a/docs/stackit_server_backup_schedule.md b/docs/stackit_server_backup_schedule.md
new file mode 100644
index 000000000..710c97b18
--- /dev/null
+++ b/docs/stackit_server_backup_schedule.md
@@ -0,0 +1,38 @@
+## stackit server backup schedule
+
+Provides functionality for Server Backup Schedule
+
+### Synopsis
+
+Provides functionality for Server Backup Schedule.
+
+```
+stackit server backup schedule [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup schedule"
+```
+
+### 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 backup](./stackit_server_backup.md) - Provides functionality for server backups
+* [stackit server backup schedule create](./stackit_server_backup_schedule_create.md) - Creates a Server Backup Schedule
+* [stackit server backup schedule delete](./stackit_server_backup_schedule_delete.md) - Deletes a Server Backup Schedule
+* [stackit server backup schedule describe](./stackit_server_backup_schedule_describe.md) - Shows details of a Server Backup Schedule
+* [stackit server backup schedule list](./stackit_server_backup_schedule_list.md) - Lists all server backup schedules
+* [stackit server backup schedule update](./stackit_server_backup_schedule_update.md) - Updates a Server Backup Schedule
+
diff --git a/docs/stackit_server_backup_schedule_create.md b/docs/stackit_server_backup_schedule_create.md
new file mode 100644
index 000000000..8b0460852
--- /dev/null
+++ b/docs/stackit_server_backup_schedule_create.md
@@ -0,0 +1,50 @@
+## stackit server backup schedule create
+
+Creates a Server Backup Schedule
+
+### Synopsis
+
+Creates a Server Backup Schedule.
+
+```
+stackit server backup schedule create [flags]
+```
+
+### Examples
+
+```
+ Create a Server Backup Schedule with name "myschedule" and backup name "mybackup"
+ $ stackit server backup schedule create --server-id xxx --backup-name=mybackup --backup-schedule-name=myschedule
+
+ 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
+```
+
+### Options
+
+```
+ -b, --backup-name string Backup name
+ -d, --backup-retention-period int Backup retention period (in days) (default 14)
+ -n, --backup-schedule-name string Backup schedule name
+ -i, --backup-volume-ids strings Backup volume IDs, as comma separated UUID values. (default [])
+ -e, --enabled Is the server backup schedule enabled (default true)
+ -h, --help Help for "stackit server backup schedule create"
+ -r, --rrule string Backup RRULE (recurrence rule) (default "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1")
+ -s, --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 backup schedule](./stackit_server_backup_schedule.md) - Provides functionality for Server Backup Schedule
+
diff --git a/docs/stackit_server_backup_schedule_delete.md b/docs/stackit_server_backup_schedule_delete.md
new file mode 100644
index 000000000..e4fbf501f
--- /dev/null
+++ b/docs/stackit_server_backup_schedule_delete.md
@@ -0,0 +1,41 @@
+## stackit server backup schedule delete
+
+Deletes a Server Backup Schedule
+
+### Synopsis
+
+Deletes a Server Backup Schedule.
+
+```
+stackit server backup schedule delete SCHEDULE_ID [flags]
+```
+
+### Examples
+
+```
+ Delete a Server Backup Schedule with ID "xxx" for server "zzz"
+ $ stackit server backup schedule delete xxx --server-id=zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup schedule delete"
+ -s, --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 backup schedule](./stackit_server_backup_schedule.md) - Provides functionality for Server Backup Schedule
+
diff --git a/docs/stackit_server_backup_schedule_describe.md b/docs/stackit_server_backup_schedule_describe.md
new file mode 100644
index 000000000..e90933b67
--- /dev/null
+++ b/docs/stackit_server_backup_schedule_describe.md
@@ -0,0 +1,44 @@
+## stackit server backup schedule describe
+
+Shows details of a Server Backup Schedule
+
+### Synopsis
+
+Shows details of a Server Backup Schedule.
+
+```
+stackit server backup schedule describe BACKUP_SCHEDULE_ID [flags]
+```
+
+### Examples
+
+```
+ Get details of a Server Backup Schedule with id "my-schedule-id"
+ $ stackit server backup schedule describe my-schedule-id
+
+ Get details of a Server Backup Schedule with id "my-schedule-id" in JSON format
+ $ stackit server backup schedule describe my-schedule-id --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup schedule describe"
+ -s, --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 backup schedule](./stackit_server_backup_schedule.md) - Provides functionality for Server Backup Schedule
+
diff --git a/docs/stackit_server_backup_schedule_list.md b/docs/stackit_server_backup_schedule_list.md
new file mode 100644
index 000000000..e5c69b1ad
--- /dev/null
+++ b/docs/stackit_server_backup_schedule_list.md
@@ -0,0 +1,45 @@
+## stackit server backup schedule list
+
+Lists all server backup schedules
+
+### Synopsis
+
+Lists all server backup schedules.
+
+```
+stackit server backup schedule list [flags]
+```
+
+### Examples
+
+```
+ List all backup schedules for a server with ID "xxx"
+ $ stackit server backup schedule list --server-id xxx
+
+ List all backup schedules for a server with ID "xxx" in JSON format
+ $ stackit server backup schedule list --server-id xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup schedule list"
+ --limit int Maximum number of entries to list
+ -s, --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 backup schedule](./stackit_server_backup_schedule.md) - Provides functionality for Server Backup Schedule
+
diff --git a/docs/stackit_server_backup_schedule_update.md b/docs/stackit_server_backup_schedule_update.md
new file mode 100644
index 000000000..522c6e7a7
--- /dev/null
+++ b/docs/stackit_server_backup_schedule_update.md
@@ -0,0 +1,50 @@
+## stackit server backup schedule update
+
+Updates a Server Backup Schedule
+
+### Synopsis
+
+Updates a Server Backup Schedule.
+
+```
+stackit server backup schedule update SCHEDULE_ID [flags]
+```
+
+### Examples
+
+```
+ Update the retention period of the backup schedule "zzz" of server "xxx"
+ $ stackit server backup schedule update zzz --server-id=xxx --backup-retention-period=20
+
+ Update the backup name of the backup schedule "zzz" of server "xxx"
+ $ stackit server backup schedule update zzz --server-id=xxx --backup-name=newname
+```
+
+### Options
+
+```
+ -b, --backup-name string Backup name
+ -d, --backup-retention-period int Backup retention period (in days) (default 14)
+ -n, --backup-schedule-name string Backup schedule name
+ -i, --backup-volume-ids strings Backup volume IDs, as comma separated UUID values. (default [])
+ -e, --enabled Is the server backup schedule enabled (default true)
+ -h, --help Help for "stackit server backup schedule update"
+ -r, --rrule string Backup RRULE (recurrence rule) (default "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1")
+ -s, --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 backup schedule](./stackit_server_backup_schedule.md) - Provides functionality for Server Backup Schedule
+
diff --git a/docs/stackit_server_backup_volume-backup.md b/docs/stackit_server_backup_volume-backup.md
new file mode 100644
index 000000000..ba8068b93
--- /dev/null
+++ b/docs/stackit_server_backup_volume-backup.md
@@ -0,0 +1,35 @@
+## stackit server backup volume-backup
+
+Provides functionality for Server Backup Volume Backups
+
+### Synopsis
+
+Provides functionality for Server Backup Volume Backups.
+
+```
+stackit server backup volume-backup [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server backup 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 server backup](./stackit_server_backup.md) - Provides functionality for server backups
+* [stackit server backup volume-backup delete](./stackit_server_backup_volume-backup_delete.md) - Deletes a Server Volume Backup.
+* [stackit server backup volume-backup restore](./stackit_server_backup_volume-backup_restore.md) - Restore a Server Volume Backup to a volume.
+
diff --git a/docs/stackit_server_backup_volume-backup_delete.md b/docs/stackit_server_backup_volume-backup_delete.md
new file mode 100644
index 000000000..9cbbdc727
--- /dev/null
+++ b/docs/stackit_server_backup_volume-backup_delete.md
@@ -0,0 +1,42 @@
+## stackit server backup volume-backup delete
+
+Deletes a Server Volume Backup.
+
+### Synopsis
+
+Deletes a Server Volume Backup. Operation always is async.
+
+```
+stackit server backup volume-backup delete VOLUME_BACKUP_ID [flags]
+```
+
+### Examples
+
+```
+ Delete a Server Volume Backup with ID "xxx" for server "zzz" and backup "bbb"
+ $ stackit server backup volume-backup delete xxx --server-id=zzz --backup-id=bbb
+```
+
+### Options
+
+```
+ -b, --backup-id string Backup ID
+ -h, --help Help for "stackit server backup volume-backup delete"
+ -s, --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 backup volume-backup](./stackit_server_backup_volume-backup.md) - Provides functionality for Server Backup Volume Backups
+
diff --git a/docs/stackit_server_backup_volume-backup_restore.md b/docs/stackit_server_backup_volume-backup_restore.md
new file mode 100644
index 000000000..622d45f6f
--- /dev/null
+++ b/docs/stackit_server_backup_volume-backup_restore.md
@@ -0,0 +1,43 @@
+## stackit server backup volume-backup restore
+
+Restore a Server Volume Backup to a volume.
+
+### Synopsis
+
+Restore a Server Volume Backup to a volume. Operation always is async.
+
+```
+stackit server backup volume-backup restore VOLUME_BACKUP_ID [flags]
+```
+
+### Examples
+
+```
+ Restore a Server Volume Backup with ID "xxx" for server "zzz" and backup "bbb" to volume "rrr"
+ $ stackit server backup volume-backup restore xxx --server-id=zzz --backup-id=bbb --restore-volume-id=rrr
+```
+
+### Options
+
+```
+ -b, --backup-id string Backup ID
+ -h, --help Help for "stackit server backup volume-backup restore"
+ -r, --restore-volume-id string Restore Volume ID
+ -s, --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 backup volume-backup](./stackit_server_backup_volume-backup.md) - Provides functionality for Server Backup Volume Backups
+
diff --git a/docs/stackit_server_command.md b/docs/stackit_server_command.md
new file mode 100644
index 000000000..c0640ba60
--- /dev/null
+++ b/docs/stackit_server_command.md
@@ -0,0 +1,37 @@
+## stackit server command
+
+Provides functionality for Server Command
+
+### Synopsis
+
+Provides functionality for Server Command.
+
+```
+stackit server command [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server command"
+```
+
+### 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 command create](./stackit_server_command_create.md) - Creates a Server Command
+* [stackit server command describe](./stackit_server_command_describe.md) - Shows details of a Server Command
+* [stackit server command list](./stackit_server_command_list.md) - Lists all server commands
+* [stackit server command template](./stackit_server_command_template.md) - Provides functionality for Server Command Template
+
diff --git a/docs/stackit_server_command_create.md b/docs/stackit_server_command_create.md
new file mode 100644
index 000000000..224e7742f
--- /dev/null
+++ b/docs/stackit_server_command_create.md
@@ -0,0 +1,46 @@
+## stackit server command create
+
+Creates a Server Command
+
+### Synopsis
+
+Creates a Server Command.
+
+```
+stackit server command create [flags]
+```
+
+### Examples
+
+```
+ Create a server command for server with ID "xxx", template name "RunShellScript" and a script from a file (using the @{...} format)
+ $ stackit server command create --server-id xxx --template-name=RunShellScript --params script='@{/path/to/script.sh}'
+
+ 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'
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server command create"
+ -r, --params stringToString Params can be provided with the format key=value and the flag can be used multiple times to provide a list of labels (default [])
+ -s, --server-id string Server ID
+ -n, --template-name string Template name
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 command](./stackit_server_command.md) - Provides functionality for Server Command
+
diff --git a/docs/stackit_server_command_describe.md b/docs/stackit_server_command_describe.md
new file mode 100644
index 000000000..61af4782c
--- /dev/null
+++ b/docs/stackit_server_command_describe.md
@@ -0,0 +1,44 @@
+## stackit server command describe
+
+Shows details of a Server Command
+
+### Synopsis
+
+Shows details of a Server Command.
+
+```
+stackit server command describe COMMAND_ID [flags]
+```
+
+### Examples
+
+```
+ Get details of a Server Command with ID "xxx" for server with ID "yyy"
+ $ stackit server command describe xxx --server-id=yyy
+
+ Get details of a Server Command with ID "xxx" for server with ID "yyy" in JSON format
+ $ stackit server command describe xxx --server-id=yyy --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server command describe"
+ -s, --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 command](./stackit_server_command.md) - Provides functionality for Server Command
+
diff --git a/docs/stackit_server_command_list.md b/docs/stackit_server_command_list.md
new file mode 100644
index 000000000..6467de601
--- /dev/null
+++ b/docs/stackit_server_command_list.md
@@ -0,0 +1,45 @@
+## stackit server command list
+
+Lists all server commands
+
+### Synopsis
+
+Lists all server commands.
+
+```
+stackit server command list [flags]
+```
+
+### Examples
+
+```
+ List all commands for a server with ID "xxx"
+ $ stackit server command list --server-id xxx
+
+ List all commands for a server with ID "xxx" in JSON format
+ $ stackit server command list --server-id xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server command list"
+ --limit int Maximum number of entries to list
+ -s, --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 command](./stackit_server_command.md) - Provides functionality for Server Command
+
diff --git a/docs/stackit_server_command_template.md b/docs/stackit_server_command_template.md
new file mode 100644
index 000000000..92a904fea
--- /dev/null
+++ b/docs/stackit_server_command_template.md
@@ -0,0 +1,35 @@
+## stackit server command template
+
+Provides functionality for Server Command Template
+
+### Synopsis
+
+Provides functionality for Server Command Template.
+
+```
+stackit server command template [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server command template"
+```
+
+### 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 command](./stackit_server_command.md) - Provides functionality for Server Command
+* [stackit server command template describe](./stackit_server_command_template_describe.md) - Shows details of a Server Command Template
+* [stackit server command template list](./stackit_server_command_template_list.md) - Lists all server command templates
+
diff --git a/docs/stackit_server_command_template_describe.md b/docs/stackit_server_command_template_describe.md
new file mode 100644
index 000000000..86a035a2b
--- /dev/null
+++ b/docs/stackit_server_command_template_describe.md
@@ -0,0 +1,44 @@
+## stackit server command template describe
+
+Shows details of a Server Command Template
+
+### Synopsis
+
+Shows details of a Server Command Template.
+
+```
+stackit server command template describe COMMAND_TEMPLATE_NAME [flags]
+```
+
+### Examples
+
+```
+ Get details of a Server Command Template with name "RunShellScript" for server with ID "xxx"
+ $ stackit server command template describe RunShellScript --server-id=xxx
+
+ Get details of a Server Command Template with name "RunShellScript" for server with ID "xxx" in JSON format
+ $ stackit server command template describe RunShellScript --server-id=xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server command template describe"
+ -s, --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 command template](./stackit_server_command_template.md) - Provides functionality for Server Command Template
+
diff --git a/docs/stackit_server_command_template_list.md b/docs/stackit_server_command_template_list.md
new file mode 100644
index 000000000..36457e6f1
--- /dev/null
+++ b/docs/stackit_server_command_template_list.md
@@ -0,0 +1,44 @@
+## stackit server command template list
+
+Lists all server command templates
+
+### Synopsis
+
+Lists all server command templates.
+
+```
+stackit server command template list [flags]
+```
+
+### Examples
+
+```
+ List all command templates
+ $ stackit server command template list
+
+ List all commands templates in JSON format
+ $ stackit server command template list --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server command template 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 server command template](./stackit_server_command_template.md) - Provides functionality for Server Command Template
+
diff --git a/docs/stackit_server_console.md b/docs/stackit_server_console.md
new file mode 100644
index 000000000..a8f6300a6
--- /dev/null
+++ b/docs/stackit_server_console.md
@@ -0,0 +1,43 @@
+## stackit server console
+
+Gets a URL for server remote console
+
+### Synopsis
+
+Gets a URL for server remote console.
+
+```
+stackit server console SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Get a URL for the server remote console with server ID "xxx"
+ $ stackit server console xxx
+
+ Get a URL for the server remote console with server ID "xxx" in JSON format
+ $ stackit server console xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server console"
+```
+
+### 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
+
diff --git a/docs/stackit_server_create.md b/docs/stackit_server_create.md
new file mode 100644
index 000000000..c1c4e9b21
--- /dev/null
+++ b/docs/stackit_server_create.md
@@ -0,0 +1,82 @@
+## stackit server create
+
+Creates a server
+
+### Synopsis
+
+Creates a server.
+
+```
+stackit server create [flags]
+```
+
+### Examples
+
+```
+ Create a server from an image with id xxx
+ $ stackit server create --machine-type t1.1 --name server1 --image-id xxx
+
+ Create a server with labels from an image with id xxx
+ $ stackit server create --machine-type t1.1 --name server1 --image-id xxx --labels key=value,foo=bar
+
+ Create a server with a boot volume
+ $ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64
+
+ Create a server with a boot volume from an existing volume
+ $ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type volume
+
+ Create a server with a keypair
+ $ stackit server create --machine-type t1.1 --name server1 --image-id xxx --keypair-name example
+
+ Create a server with a network
+ $ stackit server create --machine-type t1.1 --name server1 --image-id xxx --network-id yyy
+
+ Create a server with a network interface
+ $ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --network-interface-ids yyy
+
+ Create a server with an attached volume
+ $ 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
+```
+
+### Options
+
+```
+ --affinity-group string The affinity group the server is assigned to
+ --availability-zone string The availability zone of the server
+ --boot-volume-delete-on-termination Delete the volume during the termination of the server. Defaults to false
+ --boot-volume-performance-class string Boot volume performance class
+ --boot-volume-size int The size of the boot volume in GB. Must be provided when 'boot-volume-source-type' is 'image'
+ --boot-volume-source-id string ID of the source object of boot volume. It can be either an image or volume ID
+ --boot-volume-source-type string Type of the source object of boot volume. It can be either 'image' or 'volume'
+ -h, --help Help for "stackit server create"
+ --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/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
+ --security-groups strings The initial security groups for the server creation
+ --service-account-emails strings List of the service account mails
+ --user-data string User data that is passed via cloud-init to the server
+ --volumes strings The list of volumes attached to the server
+```
+
+### 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
+
diff --git a/docs/stackit_server_deallocate.md b/docs/stackit_server_deallocate.md
new file mode 100644
index 000000000..aa4921589
--- /dev/null
+++ b/docs/stackit_server_deallocate.md
@@ -0,0 +1,40 @@
+## stackit server deallocate
+
+Deallocates an existing server
+
+### Synopsis
+
+Deallocates an existing server.
+
+```
+stackit server deallocate SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Deallocate an existing server with ID "xxx"
+ $ stackit server deallocate xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server deallocate"
+```
+
+### 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
+
diff --git a/docs/stackit_server_delete.md b/docs/stackit_server_delete.md
new file mode 100644
index 000000000..32cf0bfe5
--- /dev/null
+++ b/docs/stackit_server_delete.md
@@ -0,0 +1,42 @@
+## stackit server delete
+
+Deletes a server
+
+### Synopsis
+
+Deletes a server.
+If the server is still in use, the deletion will fail
+
+
+```
+stackit server delete SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Delete server with ID "xxx"
+ $ stackit server delete xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server 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 server](./stackit_server.md) - Provides functionality for servers
+
diff --git a/docs/stackit_server_describe.md b/docs/stackit_server_describe.md
new file mode 100644
index 000000000..c6507dfbe
--- /dev/null
+++ b/docs/stackit_server_describe.md
@@ -0,0 +1,43 @@
+## stackit server describe
+
+Shows details of a server
+
+### Synopsis
+
+Shows details of a server.
+
+```
+stackit server describe SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Show details of a server with ID "xxx"
+ $ stackit server describe xxx
+
+ Show details of a server with ID "xxx" in JSON format
+ $ stackit server describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server 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 server](./stackit_server.md) - Provides functionality for servers
+
diff --git a/docs/stackit_server_list.md b/docs/stackit_server_list.md
new file mode 100644
index 000000000..064850236
--- /dev/null
+++ b/docs/stackit_server_list.md
@@ -0,0 +1,51 @@
+## stackit server list
+
+Lists all servers of a project
+
+### Synopsis
+
+Lists all servers of a project.
+
+```
+stackit server list [flags]
+```
+
+### Examples
+
+```
+ Lists all servers
+ $ stackit server list
+
+ Lists all servers which contains the label xxx
+ $ stackit server list --label-selector xxx
+
+ Lists all servers in JSON format
+ $ stackit server list --output-format json
+
+ Lists up to 10 servers
+ $ stackit server list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server list"
+ --label-selector string Filter by label
+ --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 server](./stackit_server.md) - Provides functionality for servers
+
diff --git a/docs/stackit_server_log.md b/docs/stackit_server_log.md
new file mode 100644
index 000000000..c1e1e7975
--- /dev/null
+++ b/docs/stackit_server_log.md
@@ -0,0 +1,47 @@
+## stackit server log
+
+Gets server console log
+
+### Synopsis
+
+Gets server console log.
+
+```
+stackit server log SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Get server console log for the server with ID "xxx"
+ $ stackit server log xxx
+
+ Get server console log for the server with ID "xxx" and limit output lines to 1000
+ $ stackit server log xxx --length 1000
+
+ Get server console log for the server with ID "xxx" in JSON format
+ $ stackit server log xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server log"
+ --length int Maximum number of lines to list (default 2000)
+```
+
+### 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
+
diff --git a/docs/stackit_server_machine-type.md b/docs/stackit_server_machine-type.md
new file mode 100644
index 000000000..4a7058bd8
--- /dev/null
+++ b/docs/stackit_server_machine-type.md
@@ -0,0 +1,35 @@
+## stackit server machine-type
+
+Provides functionality for server machine types available inside a project
+
+### Synopsis
+
+Provides functionality for server machine types available inside a project.
+
+```
+stackit server machine-type [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server machine-type"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 machine-type describe](./stackit_server_machine-type_describe.md) - Shows details of a server machine type
+* [stackit server machine-type list](./stackit_server_machine-type_list.md) - Get list of all machine types available in a project
+
diff --git a/docs/stackit_server_machine-type_describe.md b/docs/stackit_server_machine-type_describe.md
new file mode 100644
index 000000000..c79ec2a84
--- /dev/null
+++ b/docs/stackit_server_machine-type_describe.md
@@ -0,0 +1,43 @@
+## stackit server machine-type describe
+
+Shows details of a server machine type
+
+### Synopsis
+
+Shows details of a server machine type.
+
+```
+stackit server machine-type describe MACHINE_TYPE [flags]
+```
+
+### Examples
+
+```
+ Show details of a server machine type with name "xxx"
+ $ stackit server machine-type describe xxx
+
+ Show details of a server machine type with name "xxx" in JSON format
+ $ stackit server machine-type describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server machine-type 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 server machine-type](./stackit_server_machine-type.md) - Provides functionality for server machine types available inside a project
+
diff --git a/docs/stackit_server_machine-type_list.md b/docs/stackit_server_machine-type_list.md
new file mode 100644
index 000000000..fd2ed7afe
--- /dev/null
+++ b/docs/stackit_server_machine-type_list.md
@@ -0,0 +1,47 @@
+## stackit server machine-type list
+
+Get list of all machine types available in a project
+
+### Synopsis
+
+Get list of all machine types available in a project.
+
+```
+stackit server machine-type list [flags]
+```
+
+### Examples
+
+```
+ Get list of all machine types
+ $ stackit server machine-type list
+
+ Get list of all machine types in JSON format
+ $ stackit server machine-type list --output-format json
+
+ List the first 10 machine types
+ $ stackit server machine-type list --limit=10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server machine-type 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 server machine-type](./stackit_server_machine-type.md) - Provides functionality for server machine types available inside a project
+
diff --git a/docs/stackit_server_network-interface.md b/docs/stackit_server_network-interface.md
new file mode 100644
index 000000000..c198fb69f
--- /dev/null
+++ b/docs/stackit_server_network-interface.md
@@ -0,0 +1,36 @@
+## stackit server network-interface
+
+Allows attaching/detaching network interfaces to servers
+
+### Synopsis
+
+Allows attaching/detaching network interfaces to servers.
+
+```
+stackit server network-interface [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server network-interface"
+```
+
+### 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 network-interface attach](./stackit_server_network-interface_attach.md) - Attaches a network interface to a server
+* [stackit server network-interface detach](./stackit_server_network-interface_detach.md) - Detaches a network interface from a server
+* [stackit server network-interface list](./stackit_server_network-interface_list.md) - Lists all attached network interfaces of a server
+
diff --git a/docs/stackit_server_network-interface_attach.md b/docs/stackit_server_network-interface_attach.md
new file mode 100644
index 000000000..f20e49cb5
--- /dev/null
+++ b/docs/stackit_server_network-interface_attach.md
@@ -0,0 +1,47 @@
+## stackit server network-interface attach
+
+Attaches a network interface to a server
+
+### Synopsis
+
+Attaches a network interface to a server.
+
+```
+stackit server network-interface attach [flags]
+```
+
+### Examples
+
+```
+ Attach a network interface with ID "xxx" to a server with ID "yyy"
+ $ stackit server network-interface attach --network-interface-id xxx --server-id yyy
+
+ Create a network interface for network with ID "xxx" and attach it to a server with ID "yyy"
+ $ stackit server network-interface attach --network-id xxx --server-id yyy --create
+```
+
+### Options
+
+```
+ -b, --create If this is set a network interface will be created. (default false)
+ -h, --help Help for "stackit server network-interface attach"
+ --network-id string Network ID
+ --network-interface-id string Network Interface 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 network-interface](./stackit_server_network-interface.md) - Allows attaching/detaching network interfaces to servers
+
diff --git a/docs/stackit_server_network-interface_detach.md b/docs/stackit_server_network-interface_detach.md
new file mode 100644
index 000000000..19369455a
--- /dev/null
+++ b/docs/stackit_server_network-interface_detach.md
@@ -0,0 +1,47 @@
+## stackit server network-interface detach
+
+Detaches a network interface from a server
+
+### Synopsis
+
+Detaches a network interface from a server.
+
+```
+stackit server network-interface detach [flags]
+```
+
+### Examples
+
+```
+ Detach a network interface with ID "xxx" from a server with ID "yyy"
+ $ stackit server network-interface detach --network-interface-id xxx --server-id yyy
+
+ Detach and delete all network interfaces for network with ID "xxx" and detach them from a server with ID "yyy"
+ $ stackit server network-interface detach --network-id xxx --server-id yyy --delete
+```
+
+### Options
+
+```
+ -b, --delete If this is set all network interfaces will be deleted. (default false)
+ -h, --help Help for "stackit server network-interface detach"
+ --network-id string Network ID
+ --network-interface-id string Network Interface 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 network-interface](./stackit_server_network-interface.md) - Allows attaching/detaching network interfaces to servers
+
diff --git a/docs/stackit_server_network-interface_list.md b/docs/stackit_server_network-interface_list.md
new file mode 100644
index 000000000..42ed2e5b5
--- /dev/null
+++ b/docs/stackit_server_network-interface_list.md
@@ -0,0 +1,48 @@
+## stackit server network-interface list
+
+Lists all attached network interfaces of a server
+
+### Synopsis
+
+Lists all attached network interfaces of a server.
+
+```
+stackit server network-interface list [flags]
+```
+
+### Examples
+
+```
+ Lists all attached network interfaces of server with ID "xxx"
+ $ stackit server network-interface list --server-id xxx
+
+ Lists all attached network interfaces of server with ID "xxx" in JSON format
+ $ stackit server network-interface list --server-id xxx --output-format json
+
+ Lists up to 10 attached network interfaces of server with ID "xxx"
+ $ stackit server network-interface list --server-id xxx --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server network-interface list"
+ --limit int Maximum number of entries to list
+ --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 network-interface](./stackit_server_network-interface.md) - Allows attaching/detaching network interfaces to servers
+
diff --git a/docs/stackit_server_os-update.md b/docs/stackit_server_os-update.md
new file mode 100644
index 000000000..baf0ad8cc
--- /dev/null
+++ b/docs/stackit_server_os-update.md
@@ -0,0 +1,39 @@
+## stackit server os-update
+
+Provides functionality for managed server updates
+
+### Synopsis
+
+Provides functionality for managed server updates.
+
+```
+stackit server os-update [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 os-update create](./stackit_server_os-update_create.md) - Creates a Server os-update.
+* [stackit server os-update describe](./stackit_server_os-update_describe.md) - Shows details of a Server os-update
+* [stackit server os-update disable](./stackit_server_os-update_disable.md) - Disables server os-update service
+* [stackit server os-update enable](./stackit_server_os-update_enable.md) - Enables Server os-update service
+* [stackit server os-update list](./stackit_server_os-update_list.md) - Lists all server os-updates
+* [stackit server os-update schedule](./stackit_server_os-update_schedule.md) - Provides functionality for Server os-update Schedule
+
diff --git a/docs/stackit_server_os-update_create.md b/docs/stackit_server_os-update_create.md
new file mode 100644
index 000000000..4d110f8bc
--- /dev/null
+++ b/docs/stackit_server_os-update_create.md
@@ -0,0 +1,45 @@
+## stackit server os-update create
+
+Creates a Server os-update.
+
+### Synopsis
+
+Creates a Server os-update. Operation always is async.
+
+```
+stackit server os-update create [flags]
+```
+
+### Examples
+
+```
+ Create a Server os-update with name "myupdate"
+ $ stackit server os-update create --server-id xxx
+
+ 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
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update create"
+ -m, --maintenance-window int Maintenance window (in hours, 1-24) (default 23)
+ -s, --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 os-update](./stackit_server_os-update.md) - Provides functionality for managed server updates
+
diff --git a/docs/stackit_server_os-update_describe.md b/docs/stackit_server_os-update_describe.md
new file mode 100644
index 000000000..8302a131f
--- /dev/null
+++ b/docs/stackit_server_os-update_describe.md
@@ -0,0 +1,44 @@
+## stackit server os-update describe
+
+Shows details of a Server os-update
+
+### Synopsis
+
+Shows details of a Server os-update.
+
+```
+stackit server os-update describe UPDATE_ID [flags]
+```
+
+### Examples
+
+```
+ Get details of a Server os-update with id "my-os-update-id"
+ $ stackit server os-update describe my-os-update-id
+
+ Get details of a Server os-update with id "my-os-update-id" in JSON format
+ $ stackit server os-update describe my-os-update-id --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update describe"
+ -s, --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 os-update](./stackit_server_os-update.md) - Provides functionality for managed server updates
+
diff --git a/docs/stackit_server_os-update_disable.md b/docs/stackit_server_os-update_disable.md
new file mode 100644
index 000000000..5be186b0b
--- /dev/null
+++ b/docs/stackit_server_os-update_disable.md
@@ -0,0 +1,41 @@
+## stackit server os-update disable
+
+Disables server os-update service
+
+### Synopsis
+
+Disables server os-update service.
+
+```
+stackit server os-update disable [flags]
+```
+
+### Examples
+
+```
+ Disable os-update functionality for your server.
+ $ stackit server os-update disable --server-id=zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update disable"
+ -s, --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 os-update](./stackit_server_os-update.md) - Provides functionality for managed server updates
+
diff --git a/docs/stackit_server_os-update_enable.md b/docs/stackit_server_os-update_enable.md
new file mode 100644
index 000000000..fdcc98abe
--- /dev/null
+++ b/docs/stackit_server_os-update_enable.md
@@ -0,0 +1,41 @@
+## stackit server os-update enable
+
+Enables Server os-update service
+
+### Synopsis
+
+Enables Server os-update service.
+
+```
+stackit server os-update enable [flags]
+```
+
+### Examples
+
+```
+ Enable os-update functionality for your server
+ $ stackit server os-update enable --server-id=zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update enable"
+ -s, --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 os-update](./stackit_server_os-update.md) - Provides functionality for managed server updates
+
diff --git a/docs/stackit_server_os-update_list.md b/docs/stackit_server_os-update_list.md
new file mode 100644
index 000000000..97ff3bad7
--- /dev/null
+++ b/docs/stackit_server_os-update_list.md
@@ -0,0 +1,45 @@
+## stackit server os-update list
+
+Lists all server os-updates
+
+### Synopsis
+
+Lists all server os-updates.
+
+```
+stackit server os-update list [flags]
+```
+
+### Examples
+
+```
+ List all os-updates for a server with ID "xxx"
+ $ stackit server os-update list --server-id xxx
+
+ List all os-updates for a server with ID "xxx" in JSON format
+ $ stackit server os-update list --server-id xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update list"
+ --limit int Maximum number of entries to list
+ -s, --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 os-update](./stackit_server_os-update.md) - Provides functionality for managed server updates
+
diff --git a/docs/stackit_server_os-update_schedule.md b/docs/stackit_server_os-update_schedule.md
new file mode 100644
index 000000000..1cc934797
--- /dev/null
+++ b/docs/stackit_server_os-update_schedule.md
@@ -0,0 +1,38 @@
+## stackit server os-update schedule
+
+Provides functionality for Server os-update Schedule
+
+### Synopsis
+
+Provides functionality for Server os-update Schedule.
+
+```
+stackit server os-update schedule [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update schedule"
+```
+
+### 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 os-update](./stackit_server_os-update.md) - Provides functionality for managed server updates
+* [stackit server os-update schedule create](./stackit_server_os-update_schedule_create.md) - Creates a Server os-update Schedule
+* [stackit server os-update schedule delete](./stackit_server_os-update_schedule_delete.md) - Deletes a Server os-update Schedule
+* [stackit server os-update schedule describe](./stackit_server_os-update_schedule_describe.md) - Shows details of a Server os-update Schedule
+* [stackit server os-update schedule list](./stackit_server_os-update_schedule_list.md) - Lists all server os-update schedules
+* [stackit server os-update schedule update](./stackit_server_os-update_schedule_update.md) - Updates a Server os-update Schedule
+
diff --git a/docs/stackit_server_os-update_schedule_create.md b/docs/stackit_server_os-update_schedule_create.md
new file mode 100644
index 000000000..75862ef17
--- /dev/null
+++ b/docs/stackit_server_os-update_schedule_create.md
@@ -0,0 +1,48 @@
+## stackit server os-update schedule create
+
+Creates a Server os-update Schedule
+
+### Synopsis
+
+Creates a Server os-update Schedule.
+
+```
+stackit server os-update schedule create [flags]
+```
+
+### Examples
+
+```
+ Create a Server os-update Schedule with name "myschedule"
+ $ stackit server os-update schedule create --server-id xxx --name=myschedule
+
+ 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
+```
+
+### Options
+
+```
+ -e, --enabled Is the server os-update schedule enabled (default true)
+ -h, --help Help for "stackit server os-update schedule create"
+ -d, --maintenance-window int os-update maintenance window (in hours, 1-24) (default 23)
+ -n, --name string os-update schedule name
+ -r, --rrule string os-update RRULE (recurrence rule) (default "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1")
+ -s, --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 os-update schedule](./stackit_server_os-update_schedule.md) - Provides functionality for Server os-update Schedule
+
diff --git a/docs/stackit_server_os-update_schedule_delete.md b/docs/stackit_server_os-update_schedule_delete.md
new file mode 100644
index 000000000..c61c8b7ce
--- /dev/null
+++ b/docs/stackit_server_os-update_schedule_delete.md
@@ -0,0 +1,41 @@
+## stackit server os-update schedule delete
+
+Deletes a Server os-update Schedule
+
+### Synopsis
+
+Deletes a Server os-update Schedule.
+
+```
+stackit server os-update schedule delete SCHEDULE_ID [flags]
+```
+
+### Examples
+
+```
+ Delete a Server os-update Schedule with ID "xxx" for server "zzz"
+ $ stackit server os-update schedule delete xxx --server-id=zzz
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update schedule delete"
+ -s, --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 os-update schedule](./stackit_server_os-update_schedule.md) - Provides functionality for Server os-update Schedule
+
diff --git a/docs/stackit_server_os-update_schedule_describe.md b/docs/stackit_server_os-update_schedule_describe.md
new file mode 100644
index 000000000..f93d219ac
--- /dev/null
+++ b/docs/stackit_server_os-update_schedule_describe.md
@@ -0,0 +1,44 @@
+## stackit server os-update schedule describe
+
+Shows details of a Server os-update Schedule
+
+### Synopsis
+
+Shows details of a Server os-update Schedule.
+
+```
+stackit server os-update schedule describe SCHEDULE_ID [flags]
+```
+
+### Examples
+
+```
+ Get details of a Server os-update Schedule with id "my-schedule-id"
+ $ stackit server os-update schedule describe my-schedule-id
+
+ Get details of a Server os-update Schedule with id "my-schedule-id" in JSON format
+ $ stackit server os-update schedule describe my-schedule-id --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update schedule describe"
+ -s, --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 os-update schedule](./stackit_server_os-update_schedule.md) - Provides functionality for Server os-update Schedule
+
diff --git a/docs/stackit_server_os-update_schedule_list.md b/docs/stackit_server_os-update_schedule_list.md
new file mode 100644
index 000000000..3cf2d1580
--- /dev/null
+++ b/docs/stackit_server_os-update_schedule_list.md
@@ -0,0 +1,45 @@
+## stackit server os-update schedule list
+
+Lists all server os-update schedules
+
+### Synopsis
+
+Lists all server os-update schedules.
+
+```
+stackit server os-update schedule list [flags]
+```
+
+### Examples
+
+```
+ List all os-update schedules for a server with ID "xxx"
+ $ stackit server os-update schedule list --server-id xxx
+
+ 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
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server os-update schedule list"
+ --limit int Maximum number of entries to list
+ -s, --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 os-update schedule](./stackit_server_os-update_schedule.md) - Provides functionality for Server os-update Schedule
+
diff --git a/docs/stackit_server_os-update_schedule_update.md b/docs/stackit_server_os-update_schedule_update.md
new file mode 100644
index 000000000..8a29cd366
--- /dev/null
+++ b/docs/stackit_server_os-update_schedule_update.md
@@ -0,0 +1,45 @@
+## stackit server os-update schedule update
+
+Updates a Server os-update Schedule
+
+### Synopsis
+
+Updates a Server os-update Schedule.
+
+```
+stackit server os-update schedule update SCHEDULE_ID [flags]
+```
+
+### Examples
+
+```
+ Update the name of the os-update schedule "zzz" of server "xxx"
+ $ stackit server os-update schedule update zzz --server-id=xxx --name=newname
+```
+
+### Options
+
+```
+ -e, --enabled Is the server os-update schedule enabled (default true)
+ -h, --help Help for "stackit server os-update schedule update"
+ -d, --maintenance-window int Maintenance window (in hours, 1-24) (default 23)
+ -n, --name string os-update schedule name
+ -r, --rrule string os-update RRULE (recurrence rule) (default "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1")
+ -s, --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 os-update schedule](./stackit_server_os-update_schedule.md) - Provides functionality for Server os-update Schedule
+
diff --git a/docs/stackit_server_public-ip.md b/docs/stackit_server_public-ip.md
new file mode 100644
index 000000000..6ad5bfc99
--- /dev/null
+++ b/docs/stackit_server_public-ip.md
@@ -0,0 +1,35 @@
+## stackit server public-ip
+
+Allows attaching/detaching public IPs to servers
+
+### Synopsis
+
+Allows attaching/detaching public IPs to servers.
+
+```
+stackit server public-ip [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server public-ip"
+```
+
+### 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 public-ip attach](./stackit_server_public-ip_attach.md) - Attaches a public IP to a server
+* [stackit server public-ip detach](./stackit_server_public-ip_detach.md) - Detaches a public IP from a server
+
diff --git a/docs/stackit_server_public-ip_attach.md b/docs/stackit_server_public-ip_attach.md
new file mode 100644
index 000000000..a3cc5172f
--- /dev/null
+++ b/docs/stackit_server_public-ip_attach.md
@@ -0,0 +1,41 @@
+## stackit server public-ip attach
+
+Attaches a public IP to a server
+
+### Synopsis
+
+Attaches a public IP to a server.
+
+```
+stackit server public-ip attach PUBLIC_IP_ID [flags]
+```
+
+### Examples
+
+```
+ Attach a public IP with ID "xxx" to a server with ID "yyy"
+ $ stackit server public-ip attach xxx --server-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server public-ip attach"
+ --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 public-ip](./stackit_server_public-ip.md) - Allows attaching/detaching public IPs to servers
+
diff --git a/docs/stackit_server_public-ip_detach.md b/docs/stackit_server_public-ip_detach.md
new file mode 100644
index 000000000..4881e3c2d
--- /dev/null
+++ b/docs/stackit_server_public-ip_detach.md
@@ -0,0 +1,41 @@
+## stackit server public-ip detach
+
+Detaches a public IP from a server
+
+### Synopsis
+
+Detaches a public IP from a server.
+
+```
+stackit server public-ip detach PUBLIC_IP_ID [flags]
+```
+
+### Examples
+
+```
+ Detaches a public IP with ID "xxx" from a server with ID "yyy"
+ $ stackit server public-ip detach xxx --server-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server public-ip detach"
+ --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 public-ip](./stackit_server_public-ip.md) - Allows attaching/detaching public IPs to servers
+
diff --git a/docs/stackit_server_reboot.md b/docs/stackit_server_reboot.md
new file mode 100644
index 000000000..8075a67ba
--- /dev/null
+++ b/docs/stackit_server_reboot.md
@@ -0,0 +1,44 @@
+## stackit server reboot
+
+Reboots a server
+
+### Synopsis
+
+Reboots a server.
+
+```
+stackit server reboot SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Perform a soft reboot of a server with ID "xxx"
+ $ stackit server reboot xxx
+
+ Perform a hard reboot of a server with ID "xxx"
+ $ stackit server reboot xxx --hard
+```
+
+### Options
+
+```
+ -b, --hard Performs a hard reboot. (default false)
+ -h, --help Help for "stackit server reboot"
+```
+
+### 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
+
diff --git a/docs/stackit_server_rescue.md b/docs/stackit_server_rescue.md
new file mode 100644
index 000000000..4aaa5104b
--- /dev/null
+++ b/docs/stackit_server_rescue.md
@@ -0,0 +1,41 @@
+## stackit server rescue
+
+Rescues an existing server
+
+### Synopsis
+
+Rescues an existing server.
+
+```
+stackit server rescue SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Rescue an existing server with ID "xxx" using image with ID "yyy" as boot volume
+ $ stackit server rescue xxx --image-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server rescue"
+ --image-id string The image ID to be used for a temporary boot volume.
+```
+
+### 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
+
diff --git a/docs/stackit_server_resize.md b/docs/stackit_server_resize.md
new file mode 100644
index 000000000..bbcb239f5
--- /dev/null
+++ b/docs/stackit_server_resize.md
@@ -0,0 +1,41 @@
+## stackit server resize
+
+Resizes the server to the given machine type
+
+### Synopsis
+
+Resizes the server to the given machine type.
+
+```
+stackit server resize SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Resize a server with ID "xxx" to machine type "yyy"
+ $ stackit server resize xxx --machine-type yyy
+```
+
+### Options
+
+```
+ -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/products/compute-engine/server/basics/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 server](./stackit_server.md) - Provides functionality for servers
+
diff --git a/docs/stackit_server_service-account.md b/docs/stackit_server_service-account.md
new file mode 100644
index 000000000..5af599a53
--- /dev/null
+++ b/docs/stackit_server_service-account.md
@@ -0,0 +1,36 @@
+## stackit server service-account
+
+Allows attaching/detaching service accounts to servers
+
+### Synopsis
+
+Allows attaching/detaching service accounts to servers
+
+```
+stackit server service-account [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server service-account"
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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 service-account attach](./stackit_server_service-account_attach.md) - Attach a service account to a server
+* [stackit server service-account detach](./stackit_server_service-account_detach.md) - Detach a service account from a server
+* [stackit server service-account list](./stackit_server_service-account_list.md) - List all attached service accounts for a server
+
diff --git a/docs/stackit_server_service-account_attach.md b/docs/stackit_server_service-account_attach.md
new file mode 100644
index 000000000..0cf08c386
--- /dev/null
+++ b/docs/stackit_server_service-account_attach.md
@@ -0,0 +1,41 @@
+## stackit server service-account attach
+
+Attach a service account to a server
+
+### Synopsis
+
+Attach a service account to a server
+
+```
+stackit server service-account attach SERVICE_ACCOUNT_EMAIL [flags]
+```
+
+### Examples
+
+```
+ Attach a service account with mail "xxx@sa.stackit.cloud" to a server with ID "yyy"
+ $ stackit server service-account attach xxx@sa.stackit.cloud --server-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server service-account attach"
+ -s, --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 service-account](./stackit_server_service-account.md) - Allows attaching/detaching service accounts to servers
+
diff --git a/docs/stackit_server_service-account_detach.md b/docs/stackit_server_service-account_detach.md
new file mode 100644
index 000000000..87806ced3
--- /dev/null
+++ b/docs/stackit_server_service-account_detach.md
@@ -0,0 +1,41 @@
+## stackit server service-account detach
+
+Detach a service account from a server
+
+### Synopsis
+
+Detach a service account from a server
+
+```
+stackit server service-account detach SERVICE_ACCOUNT_EMAIL [flags]
+```
+
+### Examples
+
+```
+ Detach a service account with mail "xxx@sa.stackit.cloud" from a server "yyy"
+ $ stackit server service-account detach xxx@sa.stackit.cloud --server-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server service-account detach"
+ -s, --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 service-account](./stackit_server_service-account.md) - Allows attaching/detaching service accounts to servers
+
diff --git a/docs/stackit_server_service-account_list.md b/docs/stackit_server_service-account_list.md
new file mode 100644
index 000000000..78349aee3
--- /dev/null
+++ b/docs/stackit_server_service-account_list.md
@@ -0,0 +1,48 @@
+## stackit server service-account list
+
+List all attached service accounts for a server
+
+### Synopsis
+
+List all attached service accounts for a server
+
+```
+stackit server service-account list [flags]
+```
+
+### Examples
+
+```
+ List all attached service accounts for a server with ID "xxx"
+ $ stackit server service-account list --server-id xxx
+
+ List up to 10 attached service accounts for a server with ID "xxx"
+ $ stackit server service-account list --server-id xxx --limit 10
+
+ List all attached service accounts for a server with ID "xxx" in JSON format
+ $ stackit server service-account list --server-id xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server service-account list"
+ --limit int Maximum number of entries to list
+ -s, --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 service-account](./stackit_server_service-account.md) - Allows attaching/detaching service accounts to servers
+
diff --git a/docs/stackit_server_start.md b/docs/stackit_server_start.md
new file mode 100644
index 000000000..1fa89116d
--- /dev/null
+++ b/docs/stackit_server_start.md
@@ -0,0 +1,40 @@
+## stackit server start
+
+Starts an existing server or allocates the server if deallocated
+
+### Synopsis
+
+Starts an existing server or allocates the server if deallocated.
+
+```
+stackit server start SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Start an existing server with ID "xxx"
+ $ stackit server start xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server start"
+```
+
+### 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
+
diff --git a/docs/stackit_server_stop.md b/docs/stackit_server_stop.md
new file mode 100644
index 000000000..41403e1f7
--- /dev/null
+++ b/docs/stackit_server_stop.md
@@ -0,0 +1,40 @@
+## stackit server stop
+
+Stops an existing server
+
+### Synopsis
+
+Stops an existing server.
+
+```
+stackit server stop SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Stop an existing server with ID "xxx"
+ $ stackit server stop xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server stop"
+```
+
+### 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
+
diff --git a/docs/stackit_server_unrescue.md b/docs/stackit_server_unrescue.md
new file mode 100644
index 000000000..5dc30bab3
--- /dev/null
+++ b/docs/stackit_server_unrescue.md
@@ -0,0 +1,40 @@
+## stackit server unrescue
+
+Unrescues an existing server
+
+### Synopsis
+
+Unrescues an existing server.
+
+```
+stackit server unrescue SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Unrescue an existing server with ID "xxx"
+ $ stackit server unrescue xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server unrescue"
+```
+
+### 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
+
diff --git a/docs/stackit_server_update.md b/docs/stackit_server_update.md
new file mode 100644
index 000000000..3aac20259
--- /dev/null
+++ b/docs/stackit_server_update.md
@@ -0,0 +1,45 @@
+## stackit server update
+
+Updates a server
+
+### Synopsis
+
+Updates a server.
+
+```
+stackit server update SERVER_ID [flags]
+```
+
+### Examples
+
+```
+ Update server with ID "xxx" with new name "server-1-new"
+ $ stackit server update xxx --name server-1-new
+
+ Update server with ID "xxx" with new name "server-1-new" and label(s)
+ $ stackit server update xxx --name server-1-new --labels key=value,foo=bar
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server update"
+ --labels stringToString Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ -n, --name string Server name
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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
+
diff --git a/docs/stackit_server_volume.md b/docs/stackit_server_volume.md
new file mode 100644
index 000000000..74e426604
--- /dev/null
+++ b/docs/stackit_server_volume.md
@@ -0,0 +1,38 @@
+## stackit server volume
+
+Provides functionality for server volumes
+
+### Synopsis
+
+Provides functionality for server volumes.
+
+```
+stackit server volume [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server volume"
+```
+
+### 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 volume attach](./stackit_server_volume_attach.md) - Attaches a volume to a server
+* [stackit server volume describe](./stackit_server_volume_describe.md) - Describes a server volume attachment
+* [stackit server volume detach](./stackit_server_volume_detach.md) - Detaches a volume from a server
+* [stackit server volume list](./stackit_server_volume_list.md) - Lists all server volumes
+* [stackit server volume update](./stackit_server_volume_update.md) - Updates an attached volume of a server
+
diff --git a/docs/stackit_server_volume_attach.md b/docs/stackit_server_volume_attach.md
new file mode 100644
index 000000000..b7014c6a1
--- /dev/null
+++ b/docs/stackit_server_volume_attach.md
@@ -0,0 +1,45 @@
+## stackit server volume attach
+
+Attaches a volume to a server
+
+### Synopsis
+
+Attaches a volume to a server.
+
+```
+stackit server volume attach VOLUME_ID [flags]
+```
+
+### Examples
+
+```
+ Attach a volume with ID "xxx" to a server with ID "yyy"
+ $ stackit server volume attach xxx --server-id yyy
+
+ Attach a volume with ID "xxx" to a server with ID "yyy" and enable deletion on termination
+ $ stackit server volume attach xxx --server-id yyy --delete-on-termination
+```
+
+### Options
+
+```
+ -b, --delete-on-termination Delete the volume during the termination of the server. (default false)
+ -h, --help Help for "stackit server volume attach"
+ --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 volume](./stackit_server_volume.md) - Provides functionality for server volumes
+
diff --git a/docs/stackit_server_volume_describe.md b/docs/stackit_server_volume_describe.md
new file mode 100644
index 000000000..be85d6afc
--- /dev/null
+++ b/docs/stackit_server_volume_describe.md
@@ -0,0 +1,47 @@
+## stackit server volume describe
+
+Describes a server volume attachment
+
+### Synopsis
+
+Describes a server volume attachment.
+
+```
+stackit server volume describe VOLUME_ID [flags]
+```
+
+### Examples
+
+```
+ Get details of the attachment of volume with ID "xxx" to server with ID "yyy"
+ $ stackit server volume describe xxx --server-id yyy
+
+ Get details of the attachment of volume with ID "xxx" to server with ID "yyy" in JSON format
+ $ stackit server volume describe xxx --server-id yyy --output-format json
+
+ Get details of the attachment of volume with ID "xxx" to server with ID "yyy" in yaml format
+ $ stackit server volume describe xxx --server-id yyy --output-format yaml
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server volume describe"
+ --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 volume](./stackit_server_volume.md) - Provides functionality for server volumes
+
diff --git a/docs/stackit_server_volume_detach.md b/docs/stackit_server_volume_detach.md
new file mode 100644
index 000000000..3758a7b35
--- /dev/null
+++ b/docs/stackit_server_volume_detach.md
@@ -0,0 +1,41 @@
+## stackit server volume detach
+
+Detaches a volume from a server
+
+### Synopsis
+
+Detaches a volume from a server.
+
+```
+stackit server volume detach VOLUME_ID [flags]
+```
+
+### Examples
+
+```
+ Detaches a volume with ID "xxx" from a server with ID "yyy"
+ $ stackit server volume detach xxx --server-id yyy
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server volume detach"
+ --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 volume](./stackit_server_volume.md) - Provides functionality for server volumes
+
diff --git a/docs/stackit_server_volume_list.md b/docs/stackit_server_volume_list.md
new file mode 100644
index 000000000..97560e090
--- /dev/null
+++ b/docs/stackit_server_volume_list.md
@@ -0,0 +1,44 @@
+## stackit server volume list
+
+Lists all server volumes
+
+### Synopsis
+
+Lists all server volumes.
+
+```
+stackit server volume list [flags]
+```
+
+### Examples
+
+```
+ List all volumes for a server with ID "xxx"
+ $ stackit server volume list --server-id xxx
+
+ List all volumes for a server with ID "xxx" in JSON format
+ $ stackit server volumes list --server-id xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit server volume list"
+ -s, --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 volume](./stackit_server_volume.md) - Provides functionality for server volumes
+
diff --git a/docs/stackit_server_volume_update.md b/docs/stackit_server_volume_update.md
new file mode 100644
index 000000000..70290e948
--- /dev/null
+++ b/docs/stackit_server_volume_update.md
@@ -0,0 +1,42 @@
+## stackit server volume update
+
+Updates an attached volume of a server
+
+### Synopsis
+
+Updates an attached volume of a server.
+
+```
+stackit server volume update VOLUME_ID [flags]
+```
+
+### Examples
+
+```
+ Update a volume with ID "xxx" of a server with ID "yyy" and enables delete on termination
+ $ stackit server volume update xxx --server-id yyy --delete-on-termination
+```
+
+### Options
+
+```
+ -b, --delete-on-termination Delete the volume during the termination of the server. (default false)
+ -h, --help Help for "stackit server volume update"
+ --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 volume](./stackit_server_volume.md) - Provides functionality for server volumes
+
diff --git a/docs/stackit_service-account.md b/docs/stackit_service-account.md
index 4d72ed5e7..fbd524334 100644
--- a/docs/stackit_service-account.md
+++ b/docs/stackit_service-account.md
@@ -23,6 +23,7 @@ stackit service-account [flags]
--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")
```
diff --git a/docs/stackit_service-account_create.md b/docs/stackit_service-account_create.md
index 8f9d62ba3..cb1617fc7 100644
--- a/docs/stackit_service-account_create.md
+++ b/docs/stackit_service-account_create.md
@@ -31,6 +31,7 @@ stackit service-account create [flags]
--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")
```
diff --git a/docs/stackit_service-account_delete.md b/docs/stackit_service-account_delete.md
index 0e61bef0f..3e011d03f 100644
--- a/docs/stackit_service-account_delete.md
+++ b/docs/stackit_service-account_delete.md
@@ -30,6 +30,7 @@ stackit service-account delete EMAIL [flags]
--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")
```
diff --git a/docs/stackit_service-account_get-jwks.md b/docs/stackit_service-account_get-jwks.md
index b09aa4771..2c34e76fb 100644
--- a/docs/stackit_service-account_get-jwks.md
+++ b/docs/stackit_service-account_get-jwks.md
@@ -30,6 +30,7 @@ stackit service-account get-jwks EMAIL [flags]
--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")
```
diff --git a/docs/stackit_service-account_key.md b/docs/stackit_service-account_key.md
index ad9ad8aae..bb4306f77 100644
--- a/docs/stackit_service-account_key.md
+++ b/docs/stackit_service-account_key.md
@@ -23,6 +23,7 @@ stackit service-account key [flags]
--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")
```
diff --git a/docs/stackit_service-account_key_create.md b/docs/stackit_service-account_key_create.md
index 0f50cf0df..0ebcf7d02 100644
--- a/docs/stackit_service-account_key_create.md
+++ b/docs/stackit_service-account_key_create.md
@@ -41,6 +41,7 @@ stackit service-account key create [flags]
--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")
```
diff --git a/docs/stackit_service-account_key_delete.md b/docs/stackit_service-account_key_delete.md
index aa28e8413..b039f82f4 100644
--- a/docs/stackit_service-account_key_delete.md
+++ b/docs/stackit_service-account_key_delete.md
@@ -31,6 +31,7 @@ stackit service-account key delete KEY_ID [flags]
--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")
```
diff --git a/docs/stackit_service-account_key_describe.md b/docs/stackit_service-account_key_describe.md
index 58a3b922d..2ff0ac156 100644
--- a/docs/stackit_service-account_key_describe.md
+++ b/docs/stackit_service-account_key_describe.md
@@ -31,6 +31,7 @@ stackit service-account key describe KEY_ID [flags]
--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")
```
diff --git a/docs/stackit_service-account_key_list.md b/docs/stackit_service-account_key_list.md
index 811f40454..8ad4ad291 100644
--- a/docs/stackit_service-account_key_list.md
+++ b/docs/stackit_service-account_key_list.md
@@ -38,6 +38,7 @@ stackit service-account key list [flags]
--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")
```
diff --git a/docs/stackit_service-account_key_update.md b/docs/stackit_service-account_key_update.md
index 31ec346d6..f16648405 100644
--- a/docs/stackit_service-account_key_update.md
+++ b/docs/stackit_service-account_key_update.md
@@ -41,6 +41,7 @@ stackit service-account key update KEY_ID [flags]
--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")
```
diff --git a/docs/stackit_service-account_list.md b/docs/stackit_service-account_list.md
index 1ffec01ad..6e00f821c 100644
--- a/docs/stackit_service-account_list.md
+++ b/docs/stackit_service-account_list.md
@@ -31,6 +31,7 @@ stackit service-account list [flags]
--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")
```
diff --git a/docs/stackit_service-account_token.md b/docs/stackit_service-account_token.md
index 7a3a5e066..d417d9095 100644
--- a/docs/stackit_service-account_token.md
+++ b/docs/stackit_service-account_token.md
@@ -23,6 +23,7 @@ stackit service-account token [flags]
--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")
```
diff --git a/docs/stackit_service-account_token_create.md b/docs/stackit_service-account_token_create.md
index 49797b420..bcdc6fc41 100644
--- a/docs/stackit_service-account_token_create.md
+++ b/docs/stackit_service-account_token_create.md
@@ -37,6 +37,7 @@ stackit service-account token create [flags]
--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")
```
diff --git a/docs/stackit_service-account_token_list.md b/docs/stackit_service-account_token_list.md
index e340adadb..800689e4c 100644
--- a/docs/stackit_service-account_token_list.md
+++ b/docs/stackit_service-account_token_list.md
@@ -40,6 +40,7 @@ stackit service-account token list [flags]
--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")
```
diff --git a/docs/stackit_service-account_token_revoke.md b/docs/stackit_service-account_token_revoke.md
index 42a2f5fbb..d295e8168 100644
--- a/docs/stackit_service-account_token_revoke.md
+++ b/docs/stackit_service-account_token_revoke.md
@@ -33,6 +33,7 @@ stackit service-account token revoke TOKEN_ID [flags]
--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")
```
diff --git a/docs/stackit_ske.md b/docs/stackit_ske.md
index 5e8404f43..b6a307937 100644
--- a/docs/stackit_ske.md
+++ b/docs/stackit_ske.md
@@ -23,6 +23,7 @@ stackit ske [flags]
--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")
```
diff --git a/docs/stackit_ske_cluster.md b/docs/stackit_ske_cluster.md
index d98cd3bf9..a575e5495 100644
--- a/docs/stackit_ske_cluster.md
+++ b/docs/stackit_ske_cluster.md
@@ -23,16 +23,21 @@ stackit ske cluster [flags]
--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](./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 5b15cb217..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
@@ -44,6 +44,7 @@ stackit ske cluster create CLUSTER_NAME [flags]
--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")
```
diff --git a/docs/stackit_ske_cluster_delete.md b/docs/stackit_ske_cluster_delete.md
index c2bb2ccd8..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
```
@@ -30,6 +30,7 @@ stackit ske cluster delete CLUSTER_NAME [flags]
--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")
```
diff --git a/docs/stackit_ske_cluster_describe.md b/docs/stackit_ske_cluster_describe.md
index 733a9fa83..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
```
@@ -33,6 +33,7 @@ stackit ske cluster describe CLUSTER_NAME [flags]
--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")
```
diff --git a/docs/stackit_ske_cluster_generate-payload.md b/docs/stackit_ske_cluster_generate-payload.md
index 9a40bb105..d5592293f 100644
--- a/docs/stackit_ske_cluster_generate-payload.md
+++ b/docs/stackit_ske_cluster_generate-payload.md
@@ -43,6 +43,7 @@ stackit ske cluster generate-payload [flags]
--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")
```
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_list.md b/docs/stackit_ske_cluster_list.md
index d0438ef6c..a757d19b3 100644
--- a/docs/stackit_ske_cluster_list.md
+++ b/docs/stackit_ske_cluster_list.md
@@ -37,6 +37,7 @@ stackit ske cluster list [flags]
--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")
```
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 0e0482693..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
@@ -41,6 +41,7 @@ stackit ske cluster update CLUSTER_NAME [flags]
--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")
```
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.md b/docs/stackit_ske_credentials.md
index 51d65fe36..252b629e0 100644
--- a/docs/stackit_ske_credentials.md
+++ b/docs/stackit_ske_credentials.md
@@ -23,6 +23,7 @@ stackit ske credentials [flags]
--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")
```
diff --git a/docs/stackit_ske_credentials_complete-rotation.md b/docs/stackit_ske_credentials_complete-rotation.md
index 04fc73052..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]
@@ -45,6 +45,7 @@ stackit ske credentials complete-rotation CLUSTER_NAME [flags]
--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")
```
diff --git a/docs/stackit_ske_credentials_start-rotation.md b/docs/stackit_ske_credentials_start-rotation.md
index 35e3a7f5d..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]
@@ -49,6 +49,7 @@ stackit ske credentials start-rotation CLUSTER_NAME [flags]
--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")
```
diff --git a/docs/stackit_ske_describe.md b/docs/stackit_ske_describe.md
index d45433c2d..1cd29f31d 100644
--- a/docs/stackit_ske_describe.md
+++ b/docs/stackit_ske_describe.md
@@ -30,6 +30,7 @@ stackit ske describe [flags]
--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")
```
diff --git a/docs/stackit_ske_disable.md b/docs/stackit_ske_disable.md
index c9e973c6b..c86d2b10c 100644
--- a/docs/stackit_ske_disable.md
+++ b/docs/stackit_ske_disable.md
@@ -30,6 +30,7 @@ stackit ske disable [flags]
--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")
```
diff --git a/docs/stackit_ske_enable.md b/docs/stackit_ske_enable.md
index 19c88ac2d..ccc25bebb 100644
--- a/docs/stackit_ske_enable.md
+++ b/docs/stackit_ske_enable.md
@@ -30,6 +30,7 @@ stackit ske enable [flags]
--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")
```
diff --git a/docs/stackit_ske_kubeconfig.md b/docs/stackit_ske_kubeconfig.md
index d21edc87f..83634e149 100644
--- a/docs/stackit_ske_kubeconfig.md
+++ b/docs/stackit_ske_kubeconfig.md
@@ -23,12 +23,13 @@ stackit ske kubeconfig [flags]
--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](./stackit_ske.md) - Provides functionality for SKE
-* [stackit ske kubeconfig create](./stackit_ske_kubeconfig_create.md) - Creates 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 a779cacc0..476d50bab 100644
--- a/docs/stackit_ske_kubeconfig_create.md
+++ b/docs/stackit_ske_kubeconfig_create.md
@@ -1,14 +1,16 @@
## stackit ske kubeconfig create
-Creates 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.
+Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster, 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).
-By default the kubeconfig is created in the .kube folder, in the user's home directory. The kubeconfig file will be overwritten if it already exists.
-You can override this behavior by specifying a custom filepath with the --filepath 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 1h.
+
Note that the format is , e.g. 30d for 30 days and you can't combine units.
```
@@ -18,29 +20,37 @@ stackit ske kubeconfig create CLUSTER_NAME [flags]
### Examples
```
- Create a kubeconfig for the SKE cluster with name "my-cluster"
+ 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.
$ 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
+ 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.
$ stackit ske kubeconfig create my-cluster --expiration 30d
- Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 2 months
+ 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.
$ stackit ske kubeconfig create my-cluster --expiration 2M
- Create a kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath
+ 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
+ $ 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."
+ $ stackit ske kubeconfig create my-cluster --overwrite true
```
### Options
```
+ --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.
+ --overwrite Overwrite the kubeconfig file.
```
### Options inherited from parent commands
@@ -50,6 +60,7 @@ stackit ske kubeconfig create CLUSTER_NAME [flags]
--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")
```
diff --git a/docs/stackit_ske_kubeconfig_login.md b/docs/stackit_ske_kubeconfig_login.md
index d7e2d7691..0b5441533 100644
--- a/docs/stackit_ske_kubeconfig_login.md
+++ b/docs/stackit_ske_kubeconfig_login.md
@@ -36,6 +36,7 @@ stackit ske kubeconfig login [flags]
--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")
```
diff --git a/docs/stackit_ske_options.md b/docs/stackit_ske_options.md
index 303bbe6e4..76afbe93c 100644
--- a/docs/stackit_ske_options.md
+++ b/docs/stackit_ske_options.md
@@ -42,6 +42,7 @@ stackit ske options [flags]
--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")
```
diff --git a/docs/stackit_volume.md b/docs/stackit_volume.md
new file mode 100644
index 000000000..3412504c2
--- /dev/null
+++ b/docs/stackit_volume.md
@@ -0,0 +1,42 @@
+## stackit volume
+
+Provides functionality for volumes
+
+### Synopsis
+
+Provides functionality for volumes.
+
+```
+stackit volume [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit volume"
+```
+
+### 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 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_create.md b/docs/stackit_volume_create.md
new file mode 100644
index 000000000..dedf3c595
--- /dev/null
+++ b/docs/stackit_volume_create.md
@@ -0,0 +1,57 @@
+## stackit volume create
+
+Creates a volume
+
+### Synopsis
+
+Creates a volume.
+
+```
+stackit volume create [flags]
+```
+
+### Examples
+
+```
+ Create a volume with availability zone "eu01-1" and size 64 GB
+ $ stackit volume create --availability-zone eu01-1 --size 64
+
+ Create a volume with availability zone "eu01-1", size 64 GB and labels
+ $ stackit volume create --availability-zone eu01-1 --size 64 --labels key=value,foo=bar
+
+ Create a volume with name "volume-1", from a source image with ID "xxx"
+ $ stackit volume create --availability-zone eu01-1 --name volume-1 --source-id xxx --source-type image
+
+ Create a volume with availability zone "eu01-1", performance class "storage_premium_perf1" and size 64 GB
+ $ stackit volume create --availability-zone eu01-1 --performance-class storage_premium_perf1 --size 64
+```
+
+### Options
+
+```
+ --availability-zone string Availability zone
+ --description string Volume description
+ -h, --help Help for "stackit volume create"
+ --labels stringToString Labels are key-value string pairs which can be attached to a volume. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ -n, --name string Volume name
+ --performance-class string Performance class
+ --size int Volume size (GB). Either 'size' or the 'source-id' and 'source-type' flags must be given
+ --source-id string ID of the source object of volume. Either 'size' or the 'source-id' and 'source-type' flags must be given
+ --source-type string Type of the source object of volume. Either 'size' or the 'source-id' and 'source-type' flags must be given
+```
+
+### 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
+
diff --git a/docs/stackit_volume_delete.md b/docs/stackit_volume_delete.md
new file mode 100644
index 000000000..165804aa6
--- /dev/null
+++ b/docs/stackit_volume_delete.md
@@ -0,0 +1,42 @@
+## stackit volume delete
+
+Deletes a volume
+
+### Synopsis
+
+Deletes a volume.
+If the volume is still in use, the deletion will fail
+
+
+```
+stackit volume delete VOLUME_ID [flags]
+```
+
+### Examples
+
+```
+ Delete volume with ID "xxx"
+ $ stackit volume delete xxx
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit volume 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](./stackit_volume.md) - Provides functionality for volumes
+
diff --git a/docs/stackit_volume_describe.md b/docs/stackit_volume_describe.md
new file mode 100644
index 000000000..a098db3a7
--- /dev/null
+++ b/docs/stackit_volume_describe.md
@@ -0,0 +1,43 @@
+## stackit volume describe
+
+Shows details of a volume
+
+### Synopsis
+
+Shows details of a volume.
+
+```
+stackit volume describe VOLUME_ID [flags]
+```
+
+### Examples
+
+```
+ Show details of a volume with ID "xxx"
+ $ stackit volume describe xxx
+
+ Show details of a volume with ID "xxx" in JSON format
+ $ stackit volume describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit volume 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](./stackit_volume.md) - Provides functionality for volumes
+
diff --git a/docs/stackit_volume_list.md b/docs/stackit_volume_list.md
new file mode 100644
index 000000000..2e59fd0d7
--- /dev/null
+++ b/docs/stackit_volume_list.md
@@ -0,0 +1,51 @@
+## stackit volume list
+
+Lists all volumes of a project
+
+### Synopsis
+
+Lists all volumes of a project.
+
+```
+stackit volume list [flags]
+```
+
+### Examples
+
+```
+ Lists all volumes
+ $ stackit volume list
+
+ Lists all volumes which contains the label xxx
+ $ stackit volume list --label-selector xxx
+
+ Lists all volumes in JSON format
+ $ stackit volume list --output-format json
+
+ Lists up to 10 volumes
+ $ stackit volume list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit volume list"
+ --label-selector string Filter by label
+ --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](./stackit_volume.md) - Provides functionality for volumes
+
diff --git a/docs/stackit_volume_performance-class.md b/docs/stackit_volume_performance-class.md
new file mode 100644
index 000000000..f584910ab
--- /dev/null
+++ b/docs/stackit_volume_performance-class.md
@@ -0,0 +1,35 @@
+## stackit volume performance-class
+
+Provides functionality for volume performance classes available inside a project
+
+### Synopsis
+
+Provides functionality for volume performance classes available inside a project.
+
+```
+stackit volume performance-class [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit volume 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 volume](./stackit_volume.md) - Provides functionality for volumes
+* [stackit volume performance-class describe](./stackit_volume_performance-class_describe.md) - Shows details of a volume performance class
+* [stackit volume performance-class list](./stackit_volume_performance-class_list.md) - Lists all volume performance classes for a project
+
diff --git a/docs/stackit_volume_performance-class_describe.md b/docs/stackit_volume_performance-class_describe.md
new file mode 100644
index 000000000..a7c53a69c
--- /dev/null
+++ b/docs/stackit_volume_performance-class_describe.md
@@ -0,0 +1,43 @@
+## stackit volume performance-class describe
+
+Shows details of a volume performance class
+
+### Synopsis
+
+Shows details of a volume performance class.
+
+```
+stackit volume performance-class describe VOLUME_PERFORMANCE_CLASS [flags]
+```
+
+### Examples
+
+```
+ Show details of a volume performance class with name "xxx"
+ $ stackit volume performance-class describe xxx
+
+ Show details of a volume performance class with name "xxx" in JSON format
+ $ stackit volume performance-class describe xxx --output-format json
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit volume performance-class 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 performance-class](./stackit_volume_performance-class.md) - Provides functionality for volume performance classes available inside a project
+
diff --git a/docs/stackit_volume_performance-class_list.md b/docs/stackit_volume_performance-class_list.md
new file mode 100644
index 000000000..e01cd3df4
--- /dev/null
+++ b/docs/stackit_volume_performance-class_list.md
@@ -0,0 +1,51 @@
+## stackit volume performance-class list
+
+Lists all volume performance classes for a project
+
+### Synopsis
+
+Lists all volume performance classes for a project.
+
+```
+stackit volume performance-class list [flags]
+```
+
+### Examples
+
+```
+ Lists all volume performance classes
+ $ stackit volume performance-class list
+
+ Lists all volume performance classes which contains the label xxx
+ $ stackit volume performance-class list --label-selector xxx
+
+ Lists all volume performance classes in JSON format
+ $ stackit volume performance-class list --output-format json
+
+ Lists up to 10 volume performance classes
+ $ stackit volume performance-class list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit volume performance-class list"
+ --label-selector string Filter by label
+ --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 performance-class](./stackit_volume_performance-class.md) - Provides functionality for volume performance classes available inside a project
+
diff --git a/docs/stackit_volume_resize.md b/docs/stackit_volume_resize.md
new file mode 100644
index 000000000..04286ff44
--- /dev/null
+++ b/docs/stackit_volume_resize.md
@@ -0,0 +1,41 @@
+## stackit volume resize
+
+Resizes a volume
+
+### Synopsis
+
+Resizes a volume.
+
+```
+stackit volume resize VOLUME_ID [flags]
+```
+
+### Examples
+
+```
+ Resize volume with ID "xxx" with new size 10 GB
+ $ stackit volume resize xxx --size 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit volume resize"
+ --size int Volume size (GB)
+```
+
+### 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
+
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/docs/stackit_volume_update.md b/docs/stackit_volume_update.md
new file mode 100644
index 000000000..1f28e3b1e
--- /dev/null
+++ b/docs/stackit_volume_update.md
@@ -0,0 +1,49 @@
+## stackit volume update
+
+Updates a volume
+
+### Synopsis
+
+Updates a volume.
+
+```
+stackit volume update VOLUME_ID [flags]
+```
+
+### Examples
+
+```
+ Update volume with ID "xxx" with new name "volume-1-new"
+ $ stackit volume update xxx --name volume-1-new
+
+ Update volume with ID "xxx" with new name "volume-1-new" and new description "volume-1-desc-new"
+ $ stackit volume update xxx --name volume-1-new --description volume-1-desc-new
+
+ Update volume with ID "xxx" with new name "volume-1-new", new description "volume-1-desc-new" and label(s)
+ $ stackit volume update xxx --name volume-1-new --description volume-1-desc-new --labels key=value,foo=bar
+```
+
+### Options
+
+```
+ --description string Volume description
+ -h, --help Help for "stackit volume update"
+ --labels stringToString Labels are key-value string pairs which can be attached to a volume. E.g. '--labels key1=value1,key2=value2,...' (default [])
+ -n, --name string Volume name
+```
+
+### Options inherited from parent commands
+
+```
+ -y, --assume-yes If set, skips all confirmation prompts
+ --async If set, runs the command asynchronously
+ -o, --output-format string Output format, one of ["json" "pretty" "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
+
diff --git a/go.mod b/go.mod
index 3cc059542..45355a2c5 100644
--- a/go.mod
+++ b/go.mod
@@ -1,96 +1,290 @@
module github.com/stackitcloud/stackit-cli
-go 1.22
+go 1.24.0
require (
- github.com/fatih/color v1.17.0
- github.com/goccy/go-yaml v1.11.3
- github.com/golang-jwt/jwt/v5 v5.2.1
- github.com/google/go-cmp v0.6.0
+ github.com/fatih/color v1.18.0
+ github.com/goccy/go-yaml v1.19.2
+ github.com/golang-jwt/jwt/v5 v5.3.0
+ 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.5.9
- github.com/lmittmann/tint v1.0.4
- github.com/mattn/go-colorable v0.1.13
- github.com/spf13/cobra v1.8.1
- github.com/spf13/pflag v1.0.5
- github.com/spf13/viper v1.19.0
- github.com/stackitcloud/stackit-sdk-go/core v0.12.0
- github.com/stackitcloud/stackit-sdk-go/services/authorization v0.3.0
- github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0
- github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.14.0
- github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.14.0
- github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.14.0
- github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.9.0
- github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.8.0
- github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0
- github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0
- github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0
- github.com/zalando/go-keyring v0.2.5
- golang.org/x/mod v0.18.0
- golang.org/x/oauth2 v0.21.0
- golang.org/x/term v0.21.0
- golang.org/x/text v0.16.0
- k8s.io/apimachinery v0.29.2
- k8s.io/client-go v0.29.2
+ github.com/jedib0t/go-pretty/v6 v6.7.8
+ github.com/lmittmann/tint v1.1.2
+ github.com/mattn/go-colorable v0.1.14
+ 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.20.1
+ github.com/stackitcloud/stackit-sdk-go/services/alb v0.9.0
+ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.11.0
+ github.com/stackitcloud/stackit-sdk-go/services/cdn v1.9.1
+ github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3
+ github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.0
+ github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1
+ github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.0
+ github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.1
+ github.com/stackitcloud/stackit-sdk-go/services/logs v0.4.0
+ github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.5
+ github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.3
+ github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.3.2
+ github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.18.2
+ github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.3.3
+ github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.14.0
+ github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.5
+ github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.3
+ github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.11.3
+ github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.4
+ github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.0
+ github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.0
+ github.com/zalando/go-keyring v0.2.6
+ golang.org/x/mod v0.32.0
+ golang.org/x/oauth2 v0.34.0
+ golang.org/x/term v0.39.0
+ golang.org/x/text v0.33.0
+ k8s.io/apimachinery v0.34.2
+ k8s.io/client-go v0.34.2
)
require (
- golang.org/x/net v0.23.0 // indirect
- golang.org/x/time v0.5.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
)
require (
+ 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
+ 4d63.com/gochecknoglobals v0.2.2 // indirect
+ al.essio.dev/pkg/shellescape v1.5.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.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/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.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.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.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.9.0 // indirect
+ github.com/fzipp/gocyclo v0.6.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
+ github.com/go-toolsmith/astfmt v1.1.0 // indirect
+ 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.4.0 // indirect
+ github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
+ github.com/gobwas/glob v0.2.3 // 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.1 // indirect
+ github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // 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/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.2 // indirect
+ github.com/hashicorp/go-immutable-radix/v2 v2.1.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.8.2 // indirect
+ github.com/jingyugao/rowserrcheck v1.1.1 // 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.2 // indirect
+ github.com/kisielk/errcheck v1.9.0 // indirect
+ github.com/kkHAIKE/contextcheck v1.1.6 // 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.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/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
- golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
+ github.com/matttproud/golang_protobuf_extensions v1.0.1 // 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.21.2 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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.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
+ github.com/raeperd/recvcheck v0.2.0 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
+ 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.2 // indirect
+ github.com/sashamelentyev/interfacebloat v1.1.0 // 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/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.3.1 // indirect
+ github.com/stretchr/objx v0.5.2 // 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.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.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.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/multierr v1.10.0 // indirect
+ go.uber.org/zap v1.27.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // 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.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/alessio/shellescape v1.4.2 // indirect
- github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
- github.com/danieljoos/wincred v1.2.1 // indirect
+ 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.7.0 // indirect
- github.com/go-logr/logr v1.4.1 // 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/hashicorp/hcl v1.0.0 // indirect
- github.com/imdario/mergo v0.3.6 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/magiconair/properties v1.8.7 // indirect
- github.com/mattn/go-runewidth v0.0.15 // indirect
- github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/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/pelletier/go-toml/v2 v2.2.2 // indirect
- github.com/rivo/uniseg v0.4.4 // 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.4.0 // indirect
- github.com/sagikazarmark/slog-shim v0.1.0 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.11.0 // indirect
- github.com/spf13/cast v1.6.0 // indirect
- github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0
- github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.13.0
- github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0
- github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0
- github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.10.0
- github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0
- github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0
+ 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.2.0
+ github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.7.0
+ github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.3
+ github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.3
+ github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.4.2
+ github.com/stackitcloud/stackit-sdk-go/services/observability v0.15.1
+ github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.25.3
+ github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.3
+ github.com/stackitcloud/stackit-sdk-go/services/sfs v0.2.0
github.com/subosito/gotenv v1.6.0 // indirect
- go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect
- golang.org/x/sys v0.21.0 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
- gopkg.in/yaml.v2 v2.4.0 // indirect
+ golang.org/x/sys v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- k8s.io/api v0.29.2 // indirect
- k8s.io/klog/v2 v2.110.1 // indirect
- k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
- sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
- sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
- sigs.k8s.io/yaml v1.3.0 // indirect
+ k8s.io/api v0.34.2 // indirect
+ k8s.io/klog/v2 v2.130.1 // indirect
+ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
+ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
+
+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 79e1cec73..1a3cb2015 100644
--- a/go.sum
+++ b/go.sum
@@ -1,260 +1,1143 @@
-github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0=
-github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
-github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
-github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs=
-github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps=
+4d63.com/gocheckcompilerdirectives v1.3.0 h1:Ew5y5CtcAAQeTVKUVFrE7EwHMrTO6BggtEj8BZSjZ3A=
+4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY=
+4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
+4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
+al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
+al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
+cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
+cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
+cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
+cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
+cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
+cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
+cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+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.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.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/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.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.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/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=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w=
+github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
+github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
+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=
+github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s=
+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.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.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=
+github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs=
+github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
+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.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=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
-github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
-github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
-github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
+github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
+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.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=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
+github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
+github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
+github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
+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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
-github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
-github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
-github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
-github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
+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.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=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+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.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=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
-github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
-github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
-github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
-github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
-github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
-github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
-github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
-github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
-github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I=
-github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
+github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/go-toolsmith/astcast v1.1.0 h1:+JN9xZV1A+Re+95pgnMgDboWNVnIMMQXwfBwLRPgSC8=
+github.com/go-toolsmith/astcast v1.1.0/go.mod h1:qdcuFWeGGS2xX5bLM/c3U9lewg7+Zu4mr+xPwZIB4ZU=
+github.com/go-toolsmith/astcopy v1.1.0 h1:YGwBN0WM+ekI/6SS6+52zLDEf8Yvp3n2seZITCUBt5s=
+github.com/go-toolsmith/astcopy v1.1.0/go.mod h1:hXM6gan18VA1T/daUEHCFcYiW8Ai1tIwIzHY6srfEAw=
+github.com/go-toolsmith/astequal v1.0.3/go.mod h1:9Ai4UglvtR+4up+bAD4+hCj7iTo4m/OXVTSLnCyTAx4=
+github.com/go-toolsmith/astequal v1.1.0/go.mod h1:sedf7VIdCL22LD8qIvv7Nn9MuWJruQA/ysswh64lffQ=
+github.com/go-toolsmith/astequal v1.2.0 h1:3Fs3CYZ1k9Vo4FzFhwwewC3CHISHDnVUPC4x0bI2+Cw=
+github.com/go-toolsmith/astequal v1.2.0/go.mod h1:c8NZ3+kSFtFY/8lPso4v8LuJjdJiUFVnSuU3s0qrrDY=
+github.com/go-toolsmith/astfmt v1.1.0 h1:iJVPDPp6/7AaeLJEruMsBUlOYCmvg0MoCfJprsOmcco=
+github.com/go-toolsmith/astfmt v1.1.0/go.mod h1:OrcLlRwu0CuiIBp/8b5PYF9ktGVZUjlNMV634mhwuQ4=
+github.com/go-toolsmith/astp v1.1.0 h1:dXPuCl6u2llURjdPLLDxJeZInAeZ0/eZwFJmqZMnpQA=
+github.com/go-toolsmith/astp v1.1.0/go.mod h1:0T1xFGz9hicKs8Z5MfAqSUitoUYS30pDMsRVIDHs8CA=
+github.com/go-toolsmith/pkgload v1.2.2 h1:0CtmHq/02QhxcF7E9N5LIFcYFsMR5rdovfqTtRKkgIk=
+github.com/go-toolsmith/pkgload v1.2.2/go.mod h1:R2hxLNRKuAsiXCo2i5J6ZQPhnPMOVtU+f0arbFPWCus=
+github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8=
+github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQiyP2Bvw=
+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.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.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/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.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
-github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+github.com/golang-jwt/jwt/v5 v5.3.0/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=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+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.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
-github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
-github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
-github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+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.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/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/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.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=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+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.8/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=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+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-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=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
-github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
+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.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.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.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=
+github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
+github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
+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.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=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
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.5.9 h1:ACteMBRrrmm1gMsXe9PSTOClQ63IXDUt03H5U+UV8OU=
-github.com/jedib0t/go-pretty/v6 v6.5.9/go.mod h1:zbn98qrYlh95FIhwwsbIip0LYpwSG8SUOScs+v9/t0E=
+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.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=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+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.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
+github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+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=
+github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
+github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
-github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
-github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=
-github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
-github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
-github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+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.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.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
+github.com/lmittmann/tint v1.1.2/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/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+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=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+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.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
-github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
-github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/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.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=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 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/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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
+github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
+github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
+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.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=
+github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
+github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
+github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
+github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+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/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=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
+github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_golang v1.12.1 h1:ZiaPsmm9uiBeaSMRznKsCDNtPCS0T3JVDGF+06gjBzk=
+github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
+github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
+github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4=
+github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+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.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=
+github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
+github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4lu7Gd+PU1fV2/qnDNfzT635KRSObncs=
+github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ=
+github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI=
+github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
-github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
-github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
-github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
-github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
-github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
-github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
-github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
-github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
-github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
-github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
+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.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.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.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=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+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/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.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/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
-github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
-github.com/stackitcloud/stackit-sdk-go/core v0.12.0 h1:auIzUUNRuydKOScvpICP4MifGgvOajiDQd+ncGmBL0U=
-github.com/stackitcloud/stackit-sdk-go/core v0.12.0/go.mod h1:mDX1mSTsB3mP+tNBGcFNx6gH1mGBN4T+dVt+lcw7nlw=
-github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 h1:JVEx/ouHB6PlwGzQa3ywyDym1HTWo3WgrxAyXprCnuM=
-github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0/go.mod h1:nVllQfYODhX1q3bgwVTLO7wHOp+8NMLiKbn3u/Dg5nU=
-github.com/stackitcloud/stackit-sdk-go/services/authorization v0.3.0 h1:AyzBgcbd0rCm+2+xaWqtfibjWmkKlO+U+7qxqvtKpJ8=
-github.com/stackitcloud/stackit-sdk-go/services/authorization v0.3.0/go.mod h1:1sLuXa7Qvp9f+wKWdRjyNe8B2F8JX7nSTd8fBKadri4=
-github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0 h1:QIZfs6nJ/l2pOweH1E+wazXnlAUtqisVbYUxWAokTbc=
-github.com/stackitcloud/stackit-sdk-go/services/dns v0.10.0/go.mod h1:MdZcRbs19s2NLeJmSLSoqTzm9IPIQhE1ZEMpo9gePq0=
-github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.13.0 h1:W7tyIIIXgAilHpALRyrW3CrtQ2UAGZBjAG+P4tcK+QQ=
-github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v0.13.0/go.mod h1:wsO3+vXe1XiKLeCIctWAptaHQZ07Un7kmLTQ+drbj7w=
-github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0 h1:7gii3PZshOesHPCYlPycilXglk28imITIqjewySZwZ4=
-github.com/stackitcloud/stackit-sdk-go/services/logme v0.15.0/go.mod h1:bj9cn1treNSxKTRCEmESwqfENN8vCYn60HUnEA0P83c=
-github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0 h1:eYYyVUTS9Gjovg3z9+r6ctvsm1p1J4fHLa5QJbWHi0A=
-github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.15.0/go.mod h1:kPetkX9hNm9HkRyiKQL/tlgdi8frZdMP8afg0mEvQ9s=
-github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.14.0 h1:FaJYVfha+atvPfFIf3h3+BFjOjeux9OBHukG1J98kq0=
-github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.14.0/go.mod h1:iFerEzGmkg6R13ldFUyHUWHm0ac9cS4ftTDLhP0k/dU=
-github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.10.0 h1:tn1MD1nu+gYEbT3lslRI6BrapKwuvHv5Wi2Zw9uVPPc=
-github.com/stackitcloud/stackit-sdk-go/services/objectstorage v0.10.0/go.mod h1:dkVMJI88eJ3Xs0ZV15r4tUpgitUGJXcvrX3RL4Zq2bQ=
-github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.14.0 h1:zkhm0r0OZ5NbHJFrm+7B+h11QL0bNLC53nzXhqCaLWo=
-github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.14.0/go.mod h1:ZecMIf9oYj2DGZqWh93l97WdVaRdLl+tW5Fq3YKGwBM=
-github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.14.0 h1:PZAqXd8TVyTZo8qty4bM2sSoLlLG+Nc9tcpxbQhO+GY=
-github.com/stackitcloud/stackit-sdk-go/services/postgresflex v0.14.0/go.mod h1:SdrqGLCkilL6wl1+jcxmLtks2IocgIg+bsyeyYUIzR4=
-github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0 h1:Q7JxjVwb+9ugAX71AXdbfPL87HHmIIwb9LNahn6H/2o=
-github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.15.0/go.mod h1:eSgnPBknTJh7t+jVKN+xzeAh+Cg1USOlH3QCyfvG20g=
-github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0 h1:/S+LOl94FqGk5Qdi5ehsiSCh6cCPEYJDctNOD0c2dmw=
-github.com/stackitcloud/stackit-sdk-go/services/redis v0.15.0/go.mod h1:3LhiTR/DMbKR2HuleTzlFHltR1MT1KD0DeW46X6K2GE=
-github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.9.0 h1:qCbvGqdG9saRB++UlhXt5ieCCDCITROqL5K2nm38efU=
-github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.9.0/go.mod h1:p16qz/pAW8b1gEhqMpIgJfutRPeDPqQLlbVGyCo3f8o=
-github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.8.0 h1:pJBG455kmtbQFpCxcBfBK8wOuEnmsMv3h90LFcdj3q0=
-github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.8.0/go.mod h1:LX0Mcyr7/QP77zf7e05fHCJO38RMuTxr7nEDUDZ3oPQ=
-github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0 h1:JB1O0E9+L50ZaO36uz7azurvUuB5JdX5s2ZXuIdb9t8=
-github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.4.0/go.mod h1:Ni9RBJvcaXRIrDIuQBpJcuQvCQSj27crQSyc+WM4p0c=
-github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0 h1:trrJuRMzgXu6fiiMZiUx6+A1FNKEFhA1vGq5cr5Qn3U=
-github.com/stackitcloud/stackit-sdk-go/services/ske v0.16.0/go.mod h1:0fFs4R7kg+gU7FNAIzzFvlCZJz6gyZ8CFhbK3eSrAwQ=
-github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0 h1:aIXxXx6u4+6C02MPb+hdItigeKeen7m+hEEG+Ej9sNs=
-github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v0.2.0/go.mod h1:fQJOQMfasStZ8J9iGX0vTjyJoQtLqMXJ5Npb03QJk84=
+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.20.1 h1:odiuhhRXmxvEvnVTeZSN9u98edvw2Cd3DcnkepncP3M=
+github.com/stackitcloud/stackit-sdk-go/core v0.20.1/go.mod h1:fqto7M82ynGhEnpZU6VkQKYWYoFG5goC076JWXTUPRQ=
+github.com/stackitcloud/stackit-sdk-go/services/alb v0.9.0 h1:P24WoKPt14dfUiUJ4czIv+IiVmdCFQGrKgVtw23fxNg=
+github.com/stackitcloud/stackit-sdk-go/services/alb v0.9.0/go.mod h1:63XvbCslxdfWEp+0Q4OSzQrpbY4kvVODOiIEAEEVH8M=
+github.com/stackitcloud/stackit-sdk-go/services/authorization v0.11.0 h1:4YFY5PG4vP/NiEP1uxCwh+kQHEU7iHG6syuFD7NPqcw=
+github.com/stackitcloud/stackit-sdk-go/services/authorization v0.11.0/go.mod h1:v4xdRA5P8Vr+zLdHh+ODgspN0WJG04wLImIJoYjrPK4=
+github.com/stackitcloud/stackit-sdk-go/services/cdn v1.9.1 h1:PiNC8VmLqi1WUnBSPefjDXThD43Fvb87p+Y6H8onGA0=
+github.com/stackitcloud/stackit-sdk-go/services/cdn v1.9.1/go.mod h1:Nnfe/Zv4Z8F56Ljw/MfXjL0/2Ajia4bGuL/CZuvIXk8=
+github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3 h1:KD/FxU/cJIzfyMvwiOvTlSWq87ISENpHNmw/quznGnw=
+github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.3/go.mod h1:BNiIZkDqwSV1LkWDjMKxVb9pxQ/HMIsXJ0AQ8pFoAo4=
+github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.0 h1:+96JOe4oS9BhdH4kHfc5jcl9DVIZiHrMN0/PXn8uWoI=
+github.com/stackitcloud/stackit-sdk-go/services/edge v0.4.0/go.mod h1:tFDkVkK+ESBTiH2XIcMPPR/pJJmeqT1VNDghg+ZxfMI=
+github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1 h1:3JKXfI5hdcXcRVBjUZg5qprXG5rDmPnM6dsvplMk/vg=
+github.com/stackitcloud/stackit-sdk-go/services/git v0.10.1/go.mod h1:3nTaj8IGjNNGYUD2CpuXkXwc5c4giTUmoPggFhjVFxo=
+github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.0 h1:U/x0tc487X9msMS5yZYjrBAAKrCx87Trmt0kh8JiARA=
+github.com/stackitcloud/stackit-sdk-go/services/iaas v1.3.0/go.mod h1:6+5+RCDfU7eQN3+/SGdOtx7Bq9dEa2FrHz/jflgY1M4=
+github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.1 h1:WCSuqD6AoOD/D8u+YU3brMhQwYZYxu809o3uW5SH4HA=
+github.com/stackitcloud/stackit-sdk-go/services/intake v0.4.1/go.mod h1:qq6rNvOuSQ1HDZie8gy4Wzso+a9DrgOODNPyKeBljK4=
+github.com/stackitcloud/stackit-sdk-go/services/kms v1.2.0 h1:Ar2n9GKmrTN80G/Ta1R+fL5aX5nEoxL6ODVJl3emzho=
+github.com/stackitcloud/stackit-sdk-go/services/kms v1.2.0/go.mod h1:sHMFoYvVrkRZcH13DkLvp48nW+ssRVVVuwqJHDGpa5M=
+github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.7.0 h1:ZyaB4jL71p+FWI/cXgP+p6t4iw1oAeGbLLOz4cs3dmI=
+github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.7.0/go.mod h1:dYmNdSNDKUG+E0SwuFWu+c8CuMBF/l6w1bdzAHxQao0=
+github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.3 h1:fUQLWs2WsXFh+FtFDYOm1kv/gJrGBZLjhVOXJOuYfFY=
+github.com/stackitcloud/stackit-sdk-go/services/logme v0.25.3/go.mod h1:305j9bvzJ+3c4csOw4SUfLSSxRbkpL0osbvqMI89FeM=
+github.com/stackitcloud/stackit-sdk-go/services/logs v0.4.0 h1:EOUVSKvu/m5N+psxeB69IIpANev/jw6HIw2yfh/HO7w=
+github.com/stackitcloud/stackit-sdk-go/services/logs v0.4.0/go.mod h1:m4IjH1/RtJOF072kjAB0E/ejoIc++myrKmIahphfO6Q=
+github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.3 h1:Y5Ct3Zi5UcIOwjKMWpKl0nrqiq7psTf4NJv0IKgwTkc=
+github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.25.3/go.mod h1:TMl5WcpjzUiAlLWaxMKbu9ysDzFziSPgg4xLxj9jjfY=
+github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.5 h1:tPISli81nuvLc5DPqgpvYPSjTySV0wXtMtkfdNXG4CU=
+github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.5.5/go.mod h1:G/UD3tzPzzu79MiFWUYqogxdLMB+YArNHR6Yqz7Cqr0=
+github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.4.2 h1:nsC6oA1whA4ibxEuD+0Osngmnpz8dLdV6bv+9jYP4Eo=
+github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.4.2/go.mod h1:WA6QlAAQ8aaw81W0VSVoDrxOfchGkdtmn2jQL/ub/50=
+github.com/stackitcloud/stackit-sdk-go/services/observability v0.15.1 h1:zk+47GhutK2ajO4Yiek0laGm2PdXvY8BvFZc8yHFnSE=
+github.com/stackitcloud/stackit-sdk-go/services/observability v0.15.1/go.mod h1:vapb/sJqbHlf+c7pZWdE9GqrbyI8wesGvUc9o7oJ1Xk=
+github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.3 h1:CAgu3Wsmo8pA1/VWqnqLftMn7X26uDs5zctTci4WG7A=
+github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.24.3/go.mod h1:VC3vqIQIDN+8SAzhlMdrK4eXeiSaNE1JtjIGFzpgiRI=
+github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.3.2 h1:uQIpj0phDRGrV78/vhtULwxaO2cBdHwqZcFKYUrH1Hs=
+github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.3.2/go.mod h1:rPwdDiCx0eZ+yKiy6Wo6uv76LuCgFlQxkomvun1c740=
+github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.25.3 h1:a9XjDC01il+3IDQIDgg5qcJBYcsu5rrTJyMfJZPyvCg=
+github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.25.3/go.mod h1:tjbSLF5+5JFx+qNazqhakqfPlCZPzque9R4XqRZzTRc=
+github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.3 h1:AcJSIEu1QCzRughJLzVjRP5ICop0DkvV2TgFb9LS7/c=
+github.com/stackitcloud/stackit-sdk-go/services/redis v0.25.3/go.mod h1:DLXqpz1WhmOergfOLMJ4pybozz33ysOZNIO7fv9Wtfc=
+github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.18.2 h1:VDIXOvRNmSYMeF0qQ2+w4/ez04YutVDz73hSMuuOJ54=
+github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.18.2/go.mod h1:9zyEzPL4DnmU/SHq+SuMWTSO5BPxM1Z4g8Fp28n00ds=
+github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.3.3 h1:ShK5AFExNRAVUMsbeoVQhCxb7GpNSmzq15jJuaBUSFo=
+github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.3.3/go.mod h1:P1uhYJpSvhUXTnTGSEZqWf97J2+1Z6VuVwmUOlnhiwI=
+github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.14.0 h1:8cFo0UG2r9kWwUAHRBTAG5wEt4G80+wkWdjQW6DhU6Y=
+github.com/stackitcloud/stackit-sdk-go/services/secretsmanager v0.14.0/go.mod h1:dMBt/b/LXfXTDLQTCW6PRhBlbl41q7XS+5mAyBezSJk=
+github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.5 h1:pAoqz4K17ZWcLusu7Dxkx3HGQAIYCk7SmZeAu9HHUrQ=
+github.com/stackitcloud/stackit-sdk-go/services/serverbackup v1.3.5/go.mod h1:MBlzqmewliF1LKeOBdOuT+aQrtc3y7p1Kd1fWkjecKQ=
+github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.3 h1:1gLKXD91qOYUpackMuu0PdRwrm2Z8vFK+k8H7SF0xbg=
+github.com/stackitcloud/stackit-sdk-go/services/serverupdate v1.2.3/go.mod h1:V34YusCRsq/3bJ/HxUk0wslLjVWWE/QVe70AZ+XrDPE=
+github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.11.3 h1:XV3pPXpdvQjR5Z90FFutU4iqCHfejDYQAL840Y4ztLM=
+github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.11.3/go.mod h1:YNJJ1jwBWjEdLH6vECuzoslJY9jQThIvDvTa30J3D0U=
+github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.4 h1:h4aSfJPMBBcXrG/BZiLLZRvvGJesRdll4bLP7jetuKA=
+github.com/stackitcloud/stackit-sdk-go/services/serviceenablement v1.2.4/go.mod h1:Iv+svIxk5baXnvrEdvVl5JZri6a3H/2OrQDlRWmUFMI=
+github.com/stackitcloud/stackit-sdk-go/services/sfs v0.2.0 h1:DRp1p0Gb1YZSnFXgkiKTHQD9bFfqn6OC3PcsDjqGJiw=
+github.com/stackitcloud/stackit-sdk-go/services/sfs v0.2.0/go.mod h1:XHOtGgBwwCqPSoQt2ojIRb/BeOd4kICwb9RuMXXFGt8=
+github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.0 h1:Dab1jzN0u9c67lvELoWf1RuagjO3eUBRytoX8SYL8Zs=
+github.com/stackitcloud/stackit-sdk-go/services/ske v1.6.0/go.mod h1:NzcTU5GGlUF6Lys3Ra7ylRj4ZKxJr3f/29/yoE5tjPI=
+github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.0 h1:KgIRTw4gpxx8qoiaLGLbXPVDcBgCxPl60gigw+tizYc=
+github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex v1.4.0/go.mod h1:fd13ANCU/Pye8uDd/6E0I605+6PYfHuVIQpPEK2Ph6c=
+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.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.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+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/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.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.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=
+github.com/ultraware/funlen v0.2.0/go.mod h1:ZE0q4TsJ8T1SQcjmkhN/w+MceuatI6pBFSxxyteHIJA=
+github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSWoFa+g=
+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.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=
+github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4=
+github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw=
+github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg=
+github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8=
-github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
-go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
-go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
+github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
+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.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/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.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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+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=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
-golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
-golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA=
-golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
+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-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=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
-golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+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.8.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.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+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=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
-golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
-golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
-golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+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.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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
+golang.org/x/oauth2 v0.34.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=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+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.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=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
-golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
-golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
+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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
+golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
+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=
+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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
-golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
-golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
+golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+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=
+golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+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-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-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+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.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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
-golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
-google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
+google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
+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.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=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-k8s.io/api v0.29.2 h1:hBC7B9+MU+ptchxEqTNW2DkUosJpp1P+Wn6YncZ474A=
-k8s.io/api v0.29.2/go.mod h1:sdIaaKuU7P44aoyyLlikSLayT6Vb7bvJNCX105xZXY0=
-k8s.io/apimachinery v0.29.2 h1:EWGpfJ856oj11C52NRCHuU7rFDwxev48z+6DSlGNsV8=
-k8s.io/apimachinery v0.29.2/go.mod h1:6HVkd1FwxIagpYrHSwJlQqZI3G9LfYWRPAkUvLnXTKU=
-k8s.io/client-go v0.29.2 h1:FEg85el1TeZp+/vYJM7hkDlSTFZ+c5nnK44DJ4FyoRg=
-k8s.io/client-go v0.29.2/go.mod h1:knlvFZE58VpqbQpJNbCbctTVXcd35mMyAAwBdpt4jrA=
-k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
-k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
-k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=
-k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
-k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
-k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
-sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
-sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
-sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
-sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+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.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
+k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
+k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
+k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
+k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
+k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
+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-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
+k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/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-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/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 7eceed189..0a367d55a 100644
--- a/golang-ci.yaml
+++ b/golang-ci.yaml
@@ -1,100 +1,83 @@
-# 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:
- list-type: blacklist
- include-go-root: false
- packages:
- - github.com/stretchr/testify
- packages-with-error-message:
- # specify an error message to output when a blacklisted package is used
- - github.com/stretchr/testify: "do not use a testing framework"
- misspell:
- # Correct spellings using locale preferences for US or UK.
- # Default is to use a neutral variety of English.
- # Setting locale to US will correct the British spelling of 'colour' to 'color'.
- locale: US
- golint:
- min-confidence: 0.8
- gosec:
- excludes:
- # Suppressions: (see https://github.com/securego/gosec#available-rules for details)
- - G104 # "Audit errors not checked" -> which we don't need and is a badly implemented version of errcheck
- - G102 # "Bind to all interfaces" -> since this is normal in k8s
- - G304 # "File path provided as taint input" -> too many false positives
- - G307 # "Deferring unsafe method "Close" on type "io.ReadCloser" -> false positive when calling defer resp.Body.Close()
- nakedret:
- max-func-lines: 0
- revive:
- ignore-generated-header: true
- severity: error
- # https://github.com/mgechev/revive
- rules:
- - name: errorf
- - name: context-as-argument
- - name: error-return
- - name: increment-decrement
- - name: indent-error-flow
- - name: superfluous-else
- - name: unused-parameter
- - name: unreachable-code
- - name: atomic
- - name: empty-lines
- - name: early-return
- gocritic:
- enabled-tags:
- - performance
- - style
- - experimental
- disabled-checks:
- - wrapperFunc
- - typeDefFirst
- - ifElseChain
- - dupImport # https://github.com/go-critic/go-critic/issues/845
linters:
enable:
- # https://golangci-lint.run/usage/linters/
- # default linters
- - gosimple
- - govet
- - ineffassign
- - staticcheck
- - typecheck
- - unused
- # additional linters
+ - bodyclose
+ - depguard
- errorlint
- - exportloopref
+ - forcetypeassert
- gochecknoinits
- gocritic
- - gofmt
- - goimports
- gosec
- misspell
- nakedret
- revive
- - depguard
- - bodyclose
- sqlclosecheck
- wastedassign
- - forcetypeassert
- - errcheck
disable:
- - structcheck # deprecated
- - deadcode # deprecated
- - varcheck # deprecated
- noctx # false positive: finds errors with http.NewRequest that dont make sense
- unparam # false positives
-issues:
- exclude-use-default: false
+ 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
+ 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
+ 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
new file mode 100644
index 000000000..f8fe78433
--- /dev/null
+++ b/internal/cmd/affinity-groups/affinity-groups.go
@@ -0,0 +1,33 @@
+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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "affinity-group",
+ Short: "Manage server affinity groups",
+ Long: "Manage the lifecycle of server affinity groups.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, params *types.CmdParams) {
+ cmd.AddCommand(
+ 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
new file mode 100644
index 000000000..d8b0d5a1b
--- /dev/null
+++ b/internal/cmd/affinity-groups/create/create.go
@@ -0,0 +1,126 @@
+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/services/iaas/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ nameFlag = "name"
+ policyFlag = "policy"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Name string
+ Policy string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates an affinity groups",
+ Long: `Creates an affinity groups.`,
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create an affinity group with name "AFFINITY_GROUP_NAME" and policy "soft-affinity"`,
+ "$ stackit affinity-group create --name AFFINITY_GROUP_NAME --policy soft-affinity",
+ ),
+ ),
+ 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
+ }
+
+ 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
+ request := buildRequest(ctx, *model, apiClient)
+
+ result, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("create affinity group: %w", err)
+ }
+ if resp := result; resp != nil {
+ return outputResult(params.Printer, *model, *resp)
+ }
+ return fmt.Errorf("create affinity group: nil result")
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(nameFlag, "", "The name of the affinity group.")
+ cmd.Flags().String(policyFlag, "", `The policy for the affinity group. Valid values for the policy are: "hard-affinity", "hard-anti-affinity", "soft-affinity", "soft-anti-affinity"`)
+
+ if err := flags.MarkFlagsRequired(cmd, nameFlag, policyFlag); err != nil {
+ cobra.CheckErr(err)
+ }
+}
+
+func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiCreateAffinityGroupRequest {
+ req := apiClient.CreateAffinityGroup(ctx, model.ProjectId, model.Region)
+ req = req.CreateAffinityGroupPayload(
+ iaas.CreateAffinityGroupPayload{
+ Name: utils.Ptr(model.Name),
+ Policy: utils.Ptr(model.Policy),
+ },
+ )
+ 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),
+ Policy: flags.FlagToStringValue(p, cmd, policyFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func outputResult(p *print.Printer, model inputModel, resp iaas.AffinityGroup) error {
+ outputFormat := ""
+ if model.GlobalFlagModel != nil {
+ outputFormat = model.OutputFormat
+ }
+
+ 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
+ })
+}
diff --git a/internal/cmd/affinity-groups/create/create_test.go b/internal/cmd/affinity-groups/create/create_test.go
new file mode 100644
index 000000000..82dd23ef4
--- /dev/null
+++ b/internal/cmd/affinity-groups/create/create_test.go
@@ -0,0 +1,201 @@
+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-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 (
+ testName = "test-name"
+ testPolicy = "test-policy"
+ 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,
+
+ nameFlag: testName,
+ policyFlag: testPolicy,
+ }
+ 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,
+ },
+ Name: testName,
+ Policy: testPolicy,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateAffinityGroupRequest)) iaas.ApiCreateAffinityGroupRequest {
+ request := testClient.CreateAffinityGroup(testCtx, testProjectId, testRegion)
+ request = request.CreateAffinityGroupPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateAffinityGroupPayload)) iaas.CreateAffinityGroupPayload {
+ payload := iaas.CreateAffinityGroupPayload{
+ Name: utils.Ptr(testName),
+ Policy: utils.Ptr(testPolicy),
+ }
+ 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: "without name flag",
+ flagValues: fixtureFlagValues(
+ func(flagValues map[string]string) {
+ delete(flagValues, "name")
+ },
+ ),
+ isValid: false,
+ },
+ {
+ description: "without policy flag",
+ flagValues: fixtureFlagValues(
+ func(flagValues map[string]string) {
+ delete(flagValues, "policy")
+ },
+ ),
+ isValid: false,
+ },
+ {
+ description: "without name and policy flag",
+ flagValues: fixtureFlagValues(
+ func(flagValues map[string]string) {
+ delete(flagValues, "policy")
+ delete(flagValues, "name")
+ },
+ ),
+ },
+ }
+ 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.ApiCreateAffinityGroupRequest
+ }{
+ {
+ 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("Request does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ tests := []struct {
+ description string
+ model inputModel
+ response iaas.AffinityGroup
+ isValid bool
+ }{
+ {
+ description: "empty",
+ model: inputModel{},
+ response: iaas.AffinityGroup{},
+ isValid: true,
+ },
+ {
+ description: "base",
+ model: *fixtureInputModel(),
+ response: iaas.AffinityGroup{
+ Id: utils.Ptr(testProjectId),
+ Members: utils.Ptr([]string{uuid.NewString(), uuid.NewString()}),
+ Name: utils.Ptr("test-project"),
+ Policy: utils.Ptr("hard-affinity"),
+ },
+ isValid: true,
+ },
+ }
+ 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.response)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error output result: %v", err)
+ return
+ }
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ return
+ }
+ })
+ }
+}
diff --git a/internal/cmd/affinity-groups/delete/delete.go b/internal/cmd/affinity-groups/delete/delete.go
new file mode 100644
index 000000000..ba3f83efc
--- /dev/null
+++ b/internal/cmd/affinity-groups/delete/delete.go
@@ -0,0 +1,105 @@
+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"
+ 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/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 {
+ *globalflags.GlobalFlagModel
+ AffinityGroupId string
+}
+
+const (
+ affinityGroupIdArg = "AFFINITY_GROUP"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", affinityGroupIdArg),
+ Short: "Deletes an affinity group",
+ Long: `Deletes an affinity group.`,
+ Args: args.SingleArg(affinityGroupIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete an affinity group with ID "xxx"`,
+ "$ stackit affinity-group 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
+ }
+
+ affinityGroupLabel, err := iaasUtils.GetAffinityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.AffinityGroupId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get affinity group name: %v", err)
+ affinityGroupLabel = model.AffinityGroupId
+ }
+
+ 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
+ request := buildRequest(ctx, *model, apiClient)
+ err = request.Execute()
+ if err != nil {
+ return fmt.Errorf("delete affinity group: %w", err)
+ }
+ params.Printer.Info("Deleted affinity group %q for %q\n", affinityGroupLabel, projectLabel)
+
+ return nil
+ },
+ }
+ return cmd
+}
+
+func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteAffinityGroupRequest {
+ 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, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ AffinityGroupId: cliArgs[0],
+ }
+
+ 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
new file mode 100644
index 000000000..b059eb50c
--- /dev/null
+++ b/internal/cmd/affinity-groups/delete/delete_test.go
@@ -0,0 +1,182 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), &testCtxKey{}, "test")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+
+ testAffinityGroupId = uuid.NewString()
+)
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testAffinityGroupId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ AffinityGroupId: testAffinityGroupId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteAffinityGroupRequest)) iaas.ApiDeleteAffinityGroupRequest {
+ request := testClient.DeleteAffinityGroup(testCtx, testProjectId, testRegion, testAffinityGroupId)
+ 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: "without args",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "without flags",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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 input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model inputModel
+ expectedRequest iaas.ApiDeleteAffinityGroupRequest
+ }{
+ {
+ 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/affinity-groups/describe/describe.go b/internal/cmd/affinity-groups/describe/describe.go
new file mode 100644
index 000000000..52465a976
--- /dev/null
+++ b/internal/cmd/affinity-groups/describe/describe.go
@@ -0,0 +1,121 @@
+package describe
+
+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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ AffinityGroupId string
+}
+
+const (
+ affinityGroupId = "AFFINITY_GROUP_ID"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", affinityGroupId),
+ Short: "Show details of an affinity group",
+ Long: `Show details of an affinity group.`,
+ Args: args.SingleArg(affinityGroupId, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details about an affinity group with the ID "xxx"`,
+ "$ stackit affinity-group 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)
+ result, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("get affinity group: %w", err)
+ }
+
+ if err := outputResult(params.Printer, *model, *result); err != nil {
+ return err
+ }
+ return nil
+ },
+ }
+ return cmd
+}
+
+func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiGetAffinityGroupRequest {
+ return apiClient.GetAffinityGroup(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{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ AffinityGroupId: cliArgs[0],
+ }
+
+ 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.OutputFormat
+ }
+
+ return p.OutputResult(outputFormat, resp, func() error {
+ table := tables.NewTable()
+
+ if resp.HasId() {
+ table.AddRow("ID", utils.PtrString(resp.Id))
+ table.AddSeparator()
+ }
+ if resp.Name != nil {
+ table.AddRow("NAME", utils.PtrString(resp.Name))
+ table.AddSeparator()
+ }
+ if resp.Policy != nil {
+ table.AddRow("POLICY", utils.PtrString(resp.Policy))
+ table.AddSeparator()
+ }
+ if resp.HasMembers() {
+ table.AddRow("Members", utils.JoinStringPtr(resp.Members, ", "))
+ table.AddSeparator()
+ }
+
+ if err := table.Display(p); err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+ return nil
+ })
+}
diff --git a/internal/cmd/affinity-groups/describe/describe_test.go b/internal/cmd/affinity-groups/describe/describe_test.go
new file mode 100644
index 000000000..ac751003b
--- /dev/null
+++ b/internal/cmd/affinity-groups/describe/describe_test.go
@@ -0,0 +1,218 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), &testCtxKey{}, "test")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+
+ testAffinityGroupId = uuid.NewString()
+)
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testAffinityGroupId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ AffinityGroupId: testAffinityGroupId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetAffinityGroupRequest)) iaas.ApiGetAffinityGroupRequest {
+ request := testClient.GetAffinityGroup(testCtx, testProjectId, testRegion, testAffinityGroupId)
+ 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: "without args",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "without flags",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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 input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model inputModel
+ expectedRequest iaas.ApiGetAffinityGroupRequest
+ }{
+ {
+ 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) {
+ tests := []struct {
+ description string
+ model inputModel
+ isValid bool
+ response iaas.AffinityGroup
+ }{
+ {
+ description: "empty",
+ model: inputModel{},
+ isValid: true,
+ response: iaas.AffinityGroup{},
+ },
+ }
+ 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.response)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error output result: %v", err)
+ return
+ }
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ return
+ }
+ })
+ }
+}
diff --git a/internal/cmd/affinity-groups/list/list.go b/internal/cmd/affinity-groups/list/list.go
new file mode 100644
index 000000000..0a2e3013f
--- /dev/null
+++ b/internal/cmd/affinity-groups/list/list.go
@@ -0,0 +1,136 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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-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-sdk-go/services/iaas"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+}
+
+const limitFlag = "limit"
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists affinity groups",
+ Long: `Lists affinity groups.`,
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ "Lists all affinity groups",
+ "$ stackit affinity-group list",
+ ),
+ examples.NewExample(
+ "Lists up to 10 affinity groups",
+ "$ stackit affinity-group 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
+ request := buildRequest(ctx, *model, apiClient)
+ result, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("list affinity groups: %w", err)
+ }
+
+ if items := result.Items; items != nil {
+ if model.Limit != nil && len(*items) > int(*model.Limit) {
+ *items = (*items)[:*model.Limit]
+ }
+ return outputResult(params.Printer, *model, *items)
+ }
+
+ params.Printer.Outputln("No affinity groups found")
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(limitFlag, 0, "Limit the output to the first n elements")
+}
+
+func buildRequest(ctx context.Context, model inputModel, apiClient *iaas.APIClient) iaas.ApiListAffinityGroupsRequest {
+ return apiClient.ListAffinityGroups(ctx, model.ProjectId, model.Region)
+}
+
+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 outputResult(p *print.Printer, model inputModel, items []iaas.AffinityGroup) error {
+ var outputFormat string
+ if model.GlobalFlagModel != nil {
+ outputFormat = model.OutputFormat
+ }
+
+ return p.OutputResult(outputFormat, items, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "NAME", "POLICY")
+ for _, item := range items {
+ table.AddRow(
+ utils.PtrString(item.Id),
+ utils.PtrString(item.Name),
+ utils.PtrString(item.Policy),
+ )
+ table.AddSeparator()
+ }
+
+ if err := table.Display(p); err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/affinity-groups/list/list_test.go b/internal/cmd/affinity-groups/list/list_test.go
new file mode 100644
index 000000000..432640085
--- /dev/null
+++ b/internal/cmd/affinity-groups/list/list_test.go
@@ -0,0 +1,175 @@
+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-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"
+ testLimit = 10
+)
+
+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,
+ }
+ 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,
+ },
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListAffinityGroupsRequest)) iaas.ApiListAffinityGroupsRequest {
+ request := testClient.ListAffinityGroups(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: "without flags",
+ flagValues: map[string]string{},
+ 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 iaas.ApiListAffinityGroupsRequest
+ }{
+ {
+ 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("Request does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ tests := []struct {
+ description string
+ model inputModel
+ response []iaas.AffinityGroup
+ isValid bool
+ }{
+ {
+ description: "empty",
+ model: inputModel{},
+ response: []iaas.AffinityGroup{},
+ isValid: true,
+ },
+ }
+ 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.response)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error output result: %v", err)
+ return
+ }
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ return
+ }
+ })
+ }
+}
diff --git a/internal/cmd/argus/argus.go b/internal/cmd/argus/argus.go
deleted file mode 100644
index 4bf46ccaa..000000000
--- a/internal/cmd/argus/argus.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package argus
-
-import (
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/credentials"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/plans"
- scrapeconfig "github.com/stackitcloud/stackit-cli/internal/cmd/argus/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/utils"
-
- "github.com/spf13/cobra"
-)
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: "argus",
- Short: "Provides functionality for Argus",
- Long: "Provides functionality for Argus.",
- Args: args.NoArgs,
- Run: utils.CmdHelp,
- }
- addSubcommands(cmd, p)
- 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))
-}
diff --git a/internal/cmd/argus/credentials/create/create.go b/internal/cmd/argus/credentials/create/create.go
deleted file mode 100644
index 5e613a61d..000000000
--- a/internal/cmd/argus/credentials/create/create.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package create
-
-import (
- "context"
- "encoding/json"
- "fmt"
-
- "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"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
-
- "github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
-)
-
-const (
- instanceIdFlag = "instance-id"
-)
-
-type inputModel struct {
- *globalflags.GlobalFlagModel
-
- InstanceId string
-}
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: "create",
- Short: "Creates credentials for an Argus instance.",
- Long: fmt.Sprintf("%s\n%s",
- "Creates credentials (username and password) for an Argus instance.",
- "The credentials will be generated and included in the response. You won't be able to retrieve the password later."),
- Args: args.NoArgs,
- Example: examples.Build(
- examples.NewExample(
- `Create credentials for Argus instance with ID "xxx"`,
- "$ stackit argus credentials create --instance-id xxx"),
- ),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := context.Background()
- model, err := parseInput(p, cmd)
- if err != nil {
- return err
- }
-
- // Configure API client
- apiClient, err := client.ConfigureClient(p)
- if err != nil {
- return err
- }
-
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
- if err != nil {
- p.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
- }
- }
-
- // Call API
- req := buildRequest(ctx, model, apiClient)
- if err != nil {
- return err
- }
- resp, err := req.Execute()
- if err != nil {
- return fmt.Errorf("create credentials for Argus instance: %w", err)
- }
-
- return outputResult(p, model, instanceLabel, resp)
- },
- }
- configureFlags(cmd)
- return cmd
-}
-
-func configureFlags(cmd *cobra.Command) {
- cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID")
-
- err := flags.MarkFlagsRequired(cmd, instanceIdFlag)
- cobra.CheckErr(err)
-}
-
-func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
- globalFlags := globalflags.Parse(p, cmd)
- if globalFlags.ProjectId == "" {
- return nil, &cliErr.ProjectIdError{}
- }
-
- return &inputModel{
- GlobalFlagModel: globalFlags,
- InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag),
- }, nil
-}
-
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiCreateCredentialsRequest {
- req := apiClient.CreateCredentials(ctx, model.InstanceId, model.ProjectId)
- return req
-}
-
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *argus.CreateCredentialsResponse) error {
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Argus credentials: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Argus credentials: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
- p.Outputf("Created credentials for instance %q.\n\n", instanceLabel)
- // The username field cannot be set by the user so we only display it if it's not returned empty
- username := *resp.Credentials.Username
- if username != "" {
- p.Outputf("Username: %s\n", username)
- }
-
- p.Outputf("Password: %s\n", *resp.Credentials.Password)
- return nil
- }
-}
diff --git a/internal/cmd/argus/credentials/credentials.go b/internal/cmd/argus/credentials/credentials.go
deleted file mode 100644
index b5a64b94c..000000000
--- a/internal/cmd/argus/credentials/credentials.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package credentials
-
-import (
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/credentials/create"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/credentials/delete"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/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/utils"
-
- "github.com/spf13/cobra"
-)
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: "credentials",
- Short: "Provides functionality for Argus credentials",
- Long: "Provides functionality for Argus credentials.",
- Args: args.NoArgs,
- Run: utils.CmdHelp,
- }
- addSubcommands(cmd, p)
- 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))
-}
diff --git a/internal/cmd/argus/grafana/grafana.go b/internal/cmd/argus/grafana/grafana.go
deleted file mode 100644
index 19ed4368a..000000000
--- a/internal/cmd/argus/grafana/grafana.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package grafana
-
-import (
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana/describe"
- publicreadaccess "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana/public-read-access"
- singlesignon "github.com/stackitcloud/stackit-cli/internal/cmd/argus/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/utils"
-
- "github.com/spf13/cobra"
-)
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: "grafana",
- Short: "Provides functionality for the Grafana configuration of Argus instances",
- Long: "Provides functionality for the Grafana configuration of Argus instances.",
- Args: args.NoArgs,
- Run: utils.CmdHelp,
- }
- addSubcommands(cmd, p)
- 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))
-}
diff --git a/internal/cmd/argus/grafana/public-read-access/public_read_access.go b/internal/cmd/argus/grafana/public-read-access/public_read_access.go
deleted file mode 100644
index 736a6e265..000000000
--- a/internal/cmd/argus/grafana/public-read-access/public_read_access.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package publicreadaccess
-
-import (
- "fmt"
-
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana/public-read-access/disable"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/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 {
- cmd := &cobra.Command{
- Use: "public-read-access",
- Short: "Enable or disable public read access for Grafana in Argus instances",
- Long: fmt.Sprintf("%s\n%s",
- "Enable or disable public read access for Grafana in Argus instances.",
- "When enabled, anyone can access the Grafana dashboards of the instance without logging in. Otherwise, a login is required.",
- ),
- Args: args.NoArgs,
- Run: utils.CmdHelp,
- }
- addSubcommands(cmd, p)
- return cmd
-}
-
-func addSubcommands(cmd *cobra.Command, p *print.Printer) {
- cmd.AddCommand(enable.NewCmd(p))
- cmd.AddCommand(disable.NewCmd(p))
-}
diff --git a/internal/cmd/argus/grafana/single-sign-on/single_sign_on.go b/internal/cmd/argus/grafana/single-sign-on/single_sign_on.go
deleted file mode 100644
index c19147473..000000000
--- a/internal/cmd/argus/grafana/single-sign-on/single_sign_on.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package singlesignon
-
-import (
- "fmt"
-
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana/single-sign-on/disable"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/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 {
- cmd := &cobra.Command{
- Use: "single-sign-on",
- Aliases: []string{"sso"},
- Short: "Enable or disable single sign-on for Grafana in Argus instances",
- Long: fmt.Sprintf("%s\n%s",
- "Enable or disable single sign-on for Grafana in Argus instances.",
- "When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.",
- ),
- Args: args.NoArgs,
- Run: utils.CmdHelp,
- }
- addSubcommands(cmd, p)
- return cmd
-}
-
-func addSubcommands(cmd *cobra.Command, p *print.Printer) {
- cmd.AddCommand(enable.NewCmd(p))
- cmd.AddCommand(disable.NewCmd(p))
-}
diff --git a/internal/cmd/argus/instance/create/create.go b/internal/cmd/argus/instance/create/create.go
deleted file mode 100644
index b37396bb7..000000000
--- a/internal/cmd/argus/instance/create/create.go
+++ /dev/null
@@ -1,228 +0,0 @@
-package create
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
-
- "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"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
- "github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
- "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
-
- "github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
- "github.com/stackitcloud/stackit-sdk-go/services/argus/wait"
-)
-
-const (
- instanceNameFlag = "name"
- planIdFlag = "plan-id"
- planNameFlag = "plan-name"
-)
-
-type inputModel struct {
- *globalflags.GlobalFlagModel
- PlanName string
-
- InstanceName *string
- PlanId *string
-}
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: "create",
- Short: "Creates an Argus instance",
- Long: "Creates an Argus instance.",
- Args: args.NoArgs,
- Example: examples.Build(
- examples.NewExample(
- `Create an Argus instance with name "my-instance" and specify plan by name`,
- "$ stackit argus instance create --name my-instance --plan-name Monitoring-Starter-EU01"),
- examples.NewExample(
- `Create an Argus instance with name "my-instance" and specify plan by ID`,
- "$ stackit argus instance create --name my-instance --plan-id xxx"),
- ),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := context.Background()
- model, err := parseInput(p, cmd)
- if err != nil {
- return err
- }
-
- // Configure API client
- apiClient, err := client.ConfigureClient(p)
- if err != nil {
- return err
- }
-
- projectLabel, err := projectname.GetProjectName(ctx, p, cmd)
- if err != nil {
- p.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 Argus instance for project %q?", projectLabel)
- err = p.PromptForConfirmation(prompt)
- if err != nil {
- return err
- }
- }
-
- // Call API
- req, err := buildRequest(ctx, model, apiClient)
- if err != nil {
- var argusInvalidPlanError *cliErr.ArgusInvalidPlanError
- if !errors.As(err, &argusInvalidPlanError) {
- return fmt.Errorf("build Argus instance creation request: %w", err)
- }
- return err
- }
- resp, err := req.Execute()
- if err != nil {
- return fmt.Errorf("create Argus instance: %w", err)
- }
- instanceId := *resp.InstanceId
-
- // 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)
- if err != nil {
- return fmt.Errorf("wait for Argus instance creation: %w", err)
- }
- s.Stop()
- }
-
- return outputResult(p, model, projectLabel, resp)
- },
- }
- configureFlags(cmd)
- return cmd
-}
-
-func configureFlags(cmd *cobra.Command) {
- cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name")
- cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID")
- cmd.Flags().String(planNameFlag, "", "Plan name")
-
- err := flags.MarkFlagsRequired(cmd, instanceNameFlag)
- 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{}
- }
-
- planId := flags.FlagToStringPointer(p, cmd, planIdFlag)
- planName := flags.FlagToStringValue(p, cmd, planNameFlag)
-
- if planId == nil && (planName == "") {
- return nil, &cliErr.ArgusInputPlanError{
- Cmd: cmd,
- }
- }
- if planId != nil && (planName != "") {
- return nil, &cliErr.ArgusInputPlanError{
- Cmd: cmd,
- }
- }
-
- model := inputModel{
- GlobalFlagModel: globalFlags,
- InstanceName: flags.FlagToStringPointer(p, cmd, instanceNameFlag),
- PlanId: planId,
- 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)
- }
- }
-
- return &model, nil
-}
-
-type argusClient interface {
- CreateInstance(ctx context.Context, projectId string) argus.ApiCreateInstanceRequest
- ListPlansExecute(ctx context.Context, projectId string) (*argus.PlansResponse, error)
-}
-
-func buildRequest(ctx context.Context, model *inputModel, apiClient argusClient) (argus.ApiCreateInstanceRequest, error) {
- req := apiClient.CreateInstance(ctx, model.ProjectId)
-
- var planId *string
- var err error
-
- plans, err := apiClient.ListPlansExecute(ctx, model.ProjectId)
- if err != nil {
- return req, fmt.Errorf("get Argus plans: %w", err)
- }
-
- if model.PlanId == nil {
- planId, err = argusUtils.LoadPlanId(model.PlanName, plans)
- if err != nil {
- var argusInvalidPlanError *cliErr.ArgusInvalidPlanError
- if !errors.As(err, &argusInvalidPlanError) {
- return req, fmt.Errorf("load plan ID: %w", err)
- }
- return req, err
- }
- } else {
- err := argusUtils.ValidatePlanId(*model.PlanId, plans)
- if err != nil {
- return req, err
- }
- planId = model.PlanId
- }
-
- req = req.CreateInstancePayload(argus.CreateInstancePayload{
- Name: model.InstanceName,
- PlanId: planId,
- })
- return req, nil
-}
-
-func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *argus.CreateInstanceResponse) error {
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Argus instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Argus instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
- operationState := "Created"
- if model.Async {
- operationState = "Triggered creation of"
- }
- p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, *resp.InstanceId)
- return nil
- }
-}
diff --git a/internal/cmd/argus/instance/describe/describe.go b/internal/cmd/argus/instance/describe/describe.go
deleted file mode 100644
index c6e8bc197..000000000
--- a/internal/cmd/argus/instance/describe/describe.go
+++ /dev/null
@@ -1,147 +0,0 @@
-package describe
-
-import (
- "context"
- "encoding/json"
- "fmt"
-
- "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"
- "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/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/argus"
-)
-
-const (
- instanceIdArg = "INSTANCE_ID"
-)
-
-type inputModel struct {
- *globalflags.GlobalFlagModel
- InstanceId string
-}
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: fmt.Sprintf("describe %s", instanceIdArg),
- Short: "Shows details of an Argus instance",
- Long: "Shows details of an Argus instance.",
- Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
- Example: examples.Build(
- examples.NewExample(
- `Get details of an Argus instance with ID "xxx"`,
- "$ stackit argus instance describe xxx"),
- examples.NewExample(
- `Get details of an Argus instance with ID "xxx" in JSON format`,
- "$ stackit argus instance describe xxx --output-format json"),
- ),
- 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)
- if err != nil {
- return err
- }
-
- // Call API
- req := buildRequest(ctx, model, apiClient)
- resp, err := req.Execute()
- if err != nil {
- return fmt.Errorf("read Argus instance: %w", err)
- }
-
- return outputResult(p, model.OutputFormat, resp)
- },
- }
- 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,
- }
-
- if p.IsVerbosityDebug() {
- modelStr, err := print.BuildDebugStrFromInputModel(model)
- if 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
-}
-
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiGetInstanceRequest {
- req := apiClient.GetInstance(ctx, model.InstanceId, model.ProjectId)
- return req
-}
-
-func outputResult(p *print.Printer, outputFormat string, instance *argus.GetInstanceResponse) error {
- switch outputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(instance, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Argus instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Argus instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
- table := tables.NewTable()
- table.AddRow("ID", *instance.Id)
- table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
- table.AddSeparator()
- table.AddRow("STATUS", *instance.Status)
- table.AddSeparator()
- table.AddRow("PLAN NAME", *instance.PlanName)
- table.AddSeparator()
- table.AddRow("METRIC SAMPLES (PER MIN)", *instance.Instance.Plan.TotalMetricSamples)
- table.AddSeparator()
- table.AddRow("LOGS (GB)", *instance.Instance.Plan.LogsStorage)
- table.AddSeparator()
- table.AddRow("TRACES (GB)", *instance.Instance.Plan.TracesStorage)
- table.AddSeparator()
- table.AddRow("NOTIFICATION RULES", *instance.Instance.Plan.AlertRules)
- table.AddSeparator()
- table.AddRow("GRAFANA USERS", *instance.Instance.Plan.GrafanaGlobalUsers)
- table.AddSeparator()
- table.AddRow("GRAFANA URL", *instance.Instance.GrafanaUrl)
- table.AddSeparator()
- err := table.Display(p)
- if err != nil {
- return fmt.Errorf("render table: %w", err)
- }
-
- return nil
- }
-}
diff --git a/internal/cmd/argus/instance/instance.go b/internal/cmd/argus/instance/instance.go
deleted file mode 100644
index af10b9a7c..000000000
--- a/internal/cmd/argus/instance/instance.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package instance
-
-import (
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/create"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/delete"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/describe"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/instance/list"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/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/utils"
-
- "github.com/spf13/cobra"
-)
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: "instance",
- Short: "Provides functionality for Argus instances",
- Long: "Provides functionality for Argus instances.",
- Args: args.NoArgs,
- Run: utils.CmdHelp,
- }
- addSubcommands(cmd, p)
- 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))
-}
diff --git a/internal/cmd/argus/scrape-config/scrape_config.go b/internal/cmd/argus/scrape-config/scrape_config.go
deleted file mode 100644
index 781569aa9..000000000
--- a/internal/cmd/argus/scrape-config/scrape_config.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package scrapeconfig
-
-import (
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/create"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/delete"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/describe"
- generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/generate-payload"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/scrape-config/list"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus/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/utils"
-
- "github.com/spf13/cobra"
-)
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: "scrape-config",
- Short: "Provides functionality for scrape configurations in Argus",
- Long: "Provides functionality for scrape configurations in Argus.",
- Args: args.NoArgs,
- Run: utils.CmdHelp,
- }
- addSubcommands(cmd, p)
- 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))
-}
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 c0d806518..3b87d23f5 100644
--- a/internal/cmd/auth/activate-service-account/activate_service_account.go
+++ b/internal/cmd/auth/activate-service-account/activate_service_account.go
@@ -4,8 +4,12 @@ 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"
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"
@@ -20,19 +24,17 @@ const (
serviceAccountTokenFlag = "service-account-token"
serviceAccountKeyPathFlag = "service-account-key-path"
privateKeyPathFlag = "private-key-path"
- tokenCustomEndpointFlag = "token-custom-endpoint"
- jwksCustomEndpointFlag = "jwks-custom-endpoint"
+ onlyPrintAccessTokenFlag = "only-print-access-token" // #nosec G101
)
type inputModel struct {
ServiceAccountToken string
ServiceAccountKeyPath string
PrivateKeyPath string
- TokenCustomEndpoint string
- JwksCustomEndpoint string
+ 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",
@@ -52,33 +54,41 @@ func NewCmd(p *print.Printer) *cobra.Command {
examples.NewExample(
`Activate service account authentication in the STACKIT CLI using the service account token`,
"$ stackit auth activate-service-account --service-account-token my-service-account-token"),
+ examples.NewExample(
+ `Only print the corresponding access token by using the service account token. This access token can be stored as environment variable (STACKIT_ACCESS_TOKEN) in order to be used for all subsequent commands.`,
+ "$ stackit auth activate-service-account --service-account-token my-service-account-token --only-print-access-token",
+ ),
),
RunE: func(cmd *cobra.Command, args []string) error {
- model := parseInput(p, cmd)
-
- err := storeFlags(model)
+ model, err := parseInput(params.Printer, cmd, args)
if err != nil {
return err
}
+ tokenCustomEndpoint := viper.GetString(config.TokenCustomEndpointKey)
+ if !model.OnlyPrintAccessToken {
+ if err := storeCustomEndpoint(tokenCustomEndpoint); err != nil {
+ return err
+ }
+ }
+
cfg := &sdkConfig.Configuration{
Token: model.ServiceAccountToken,
ServiceAccountKeyPath: model.ServiceAccountKeyPath,
PrivateKeyPath: model.PrivateKeyPath,
- TokenCustomUrl: model.TokenCustomEndpoint,
- JWKSCustomUrl: model.JwksCustomEndpoint,
+ TokenCustomUrl: tokenCustomEndpoint,
}
// Setup authentication based on the provided credentials and the environment
// 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, err := auth.AuthenticateServiceAccount(p, rt)
+ email, accessToken, err := auth.AuthenticateServiceAccount(params.Printer, rt, model.OnlyPrintAccessToken)
if err != nil {
var activateServiceAccountError *cliErr.ActivateServiceAccountError
if !errors.As(err, &activateServiceAccountError) {
@@ -87,8 +97,12 @@ func NewCmd(p *print.Printer) *cobra.Command {
return err
}
- p.Info("You have been successfully authenticated to the STACKIT CLI!\nService account email: %s\n", email)
-
+ if model.OnlyPrintAccessToken {
+ // Only output is the access token
+ params.Printer.Outputf("%s\n", accessToken)
+ } else {
+ params.Printer.Outputf("You have been successfully authenticated to the STACKIT CLI!\nService account email: %s\n", email)
+ }
return nil
},
}
@@ -100,39 +114,21 @@ func configureFlags(cmd *cobra.Command) {
cmd.Flags().String(serviceAccountTokenFlag, "", "Service account long-lived access token")
cmd.Flags().String(serviceAccountKeyPathFlag, "", "Service account key path")
cmd.Flags().String(privateKeyPathFlag, "", "RSA private key path. It takes precedence over the private key included in the service account key, if present")
- cmd.Flags().String(tokenCustomEndpointFlag, "", "Custom endpoint for the token API, which is used to request access tokens when the service-account authentication is activated")
- cmd.Flags().String(jwksCustomEndpointFlag, "", "Custom endpoint for the jwks API, which is used to get the json web key sets (jwks) to validate tokens when the service-account authentication is activated")
+ 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),
PrivateKeyPath: flags.FlagToStringValue(p, cmd, privateKeyPathFlag),
- TokenCustomEndpoint: flags.FlagToStringValue(p, cmd, tokenCustomEndpointFlag),
- JwksCustomEndpoint: flags.FlagToStringValue(p, cmd, jwksCustomEndpointFlag),
- }
-
- if p.IsVerbosityDebug() {
- modelStr, err := print.BuildDebugStrFromInputModel(model)
- if err != nil {
- p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
- } else {
- p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
- }
+ OnlyPrintAccessToken: flags.FlagToBoolValue(p, cmd, onlyPrintAccessTokenFlag),
}
- return &model
+ p.DebugInputModel(model)
+ return &model, nil
}
-func storeFlags(model *inputModel) error {
- err := auth.SetAuthField(auth.TOKEN_CUSTOM_ENDPOINT, model.TokenCustomEndpoint)
- if err != nil {
- return fmt.Errorf("set %s: %w", auth.TOKEN_CUSTOM_ENDPOINT, err)
- }
- err = auth.SetAuthField(auth.JWKS_CUSTOM_ENDPOINT, model.JwksCustomEndpoint)
- if err != nil {
- return fmt.Errorf("set %s: %w", auth.JWKS_CUSTOM_ENDPOINT, err)
- }
- return nil
+func storeCustomEndpoint(tokenCustomEndpoint string) error {
+ return auth.SetAuthField(auth.TOKEN_CUSTOM_ENDPOINT, tokenCustomEndpoint)
}
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 a9e12b30c..026ba8dce 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,21 +3,22 @@ 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/globalflags"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/config"
"github.com/zalando/go-keyring"
-
- "github.com/google/go-cmp/cmp"
)
+var testTokenCustomEndpoint = "token_url"
+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
serviceAccountTokenFlag: "token",
serviceAccountKeyPathFlag: "sa_key",
privateKeyPathFlag: "private_key",
- tokenCustomEndpointFlag: "token_url",
- jwksCustomEndpointFlag: "jwks_url",
+ onlyPrintAccessTokenFlag: "true",
}
for _, mod := range mods {
mod(flagValues)
@@ -30,8 +31,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
ServiceAccountToken: "token",
ServiceAccountKeyPath: "sa_key",
PrivateKeyPath: "private_key",
- TokenCustomEndpoint: "token_url",
- JwksCustomEndpoint: "jwks_url",
+ OnlyPrintAccessToken: true,
}
for _, mod := range mods {
mod(model)
@@ -41,27 +41,29 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
func TestParseInput(t *testing.T) {
tests := []struct {
- description string
- flagValues map[string]string
- isValid bool
- expectedModel *inputModel
+ description string
+ argValues []string
+ flagValues map[string]string
+ tokenCustomEndpoint string
+ isValid bool
+ expectedModel *inputModel
}{
{
- description: "base",
- flagValues: fixtureFlagValues(),
- isValid: true,
- expectedModel: fixtureInputModel(),
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ tokenCustomEndpoint: testTokenCustomEndpoint,
+ isValid: true,
+ expectedModel: fixtureInputModel(),
},
{
- description: "no values",
- flagValues: map[string]string{},
- isValid: true,
+ description: "no values",
+ flagValues: map[string]string{},
+ tokenCustomEndpoint: "",
+ isValid: true,
expectedModel: &inputModel{
ServiceAccountToken: "",
ServiceAccountKeyPath: "",
PrivateKeyPath: "",
- TokenCustomEndpoint: "",
- JwksCustomEndpoint: "",
},
},
{
@@ -70,16 +72,13 @@ func TestParseInput(t *testing.T) {
serviceAccountTokenFlag: "",
serviceAccountKeyPathFlag: "",
privateKeyPathFlag: "",
- tokenCustomEndpointFlag: "",
- jwksCustomEndpointFlag: "",
},
- isValid: true,
+ tokenCustomEndpoint: "",
+ isValid: true,
expectedModel: &inputModel{
ServiceAccountToken: "",
ServiceAccountKeyPath: "",
PrivateKeyPath: "",
- TokenCustomEndpoint: "",
- JwksCustomEndpoint: "",
},
},
{
@@ -89,50 +88,39 @@ func TestParseInput(t *testing.T) {
}),
isValid: false,
},
+ {
+ description: "default value OnlyPrintAccessToken",
+ flagValues: fixtureFlagValues(
+ func(flagValues map[string]string) {
+ delete(flagValues, "only-print-access-token")
+ },
+ ),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.OnlyPrintAccessToken = false
+ }),
+ },
}
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)
})
}
}
-func TestStoreFlags(t *testing.T) {
+func TestStoreCustomEndpointFlags(t *testing.T) {
tests := []struct {
- description string
- model *inputModel
- isValid bool
+ description string
+ model *inputModel
+ tokenCustomEndpoint string
+ isValid bool
}{
{
- description: "base",
- model: fixtureInputModel(),
- isValid: true,
+ description: "base",
+ model: fixtureInputModel(),
+ tokenCustomEndpoint: testTokenCustomEndpoint,
+ isValid: true,
},
{
description: "no values",
@@ -140,10 +128,9 @@ func TestStoreFlags(t *testing.T) {
ServiceAccountToken: "",
ServiceAccountKeyPath: "",
PrivateKeyPath: "",
- TokenCustomEndpoint: "",
- JwksCustomEndpoint: "",
},
- isValid: true,
+ tokenCustomEndpoint: "",
+ isValid: true,
},
}
@@ -152,7 +139,10 @@ func TestStoreFlags(t *testing.T) {
// Initialize an empty keyring
keyring.MockInit()
- err := storeFlags(tt.model)
+ viper.Reset()
+ viper.Set(config.TokenCustomEndpointKey, tt.tokenCustomEndpoint)
+
+ err := storeCustomEndpoint(tt.tokenCustomEndpoint)
if !tt.isValid {
if err == nil {
t.Fatalf("did not fail on invalid input")
@@ -167,16 +157,8 @@ func TestStoreFlags(t *testing.T) {
if err != nil {
t.Errorf("Failed to get value of auth field: %v", err)
}
- if value != tt.model.TokenCustomEndpoint {
- t.Errorf("Value of \"%s\" does not match: expected \"%s\", got \"%s\"", auth.TOKEN_CUSTOM_ENDPOINT, tt.model.TokenCustomEndpoint, value)
- }
-
- value, err = auth.GetAuthField(auth.JWKS_CUSTOM_ENDPOINT)
- if err != nil {
- t.Errorf("Failed to get value of auth field: %v", err)
- }
- if value != tt.model.JwksCustomEndpoint {
- t.Errorf("Value of \"%s\" does not match: expected \"%s\", got \"%s\"", auth.JWKS_CUSTOM_ENDPOINT, tt.model.TokenCustomEndpoint, value)
+ if value != tt.tokenCustomEndpoint {
+ t.Errorf("Value of \"%s\" does not match: expected \"%s\", got \"%s\"", auth.TOKEN_CUSTOM_ENDPOINT, tt.tokenCustomEndpoint, value)
}
})
}
diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go
index 061e5e85e..d54f3fb01 100644
--- a/internal/cmd/auth/auth.go
+++ b/internal/cmd/auth/auth.go
@@ -2,15 +2,17 @@ package auth
import (
activateserviceaccount "github.com/stackitcloud/stackit-cli/internal/cmd/auth/activate-service-account"
+ getaccesstoken "github.com/stackitcloud/stackit-cli/internal/cmd/auth/get-access-token"
"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",
@@ -18,11 +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(activateserviceaccount.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
new file mode 100644
index 000000000..a3c1246e6
--- /dev/null
+++ b/internal/cmd/auth/get-access-token/get_access_token.go
@@ -0,0 +1,90 @@
+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"
+)
+
+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.",
+ Long: "Prints a short-lived access token which can be used e.g. for API calls.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Print a short-lived access token`,
+ "$ stackit auth get-access-token"),
+ ),
+ 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
+ }
+ 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{}
+ }
+
+ 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))
+
+ 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 3ba46f3d5..23efd0a4e 100644
--- a/internal/cmd/auth/login/login.go
+++ b/internal/cmd/auth/login/login.go
@@ -3,32 +3,36 @@ package login
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: "login",
Short: "Logs in to the STACKIT CLI",
- Long: "Logs in to the STACKIT CLI using a user account.",
- Args: args.NoArgs,
+ Long: fmt.Sprintf("%s\n%s",
+ "Logs in to the STACKIT CLI using a user account.",
+ "The authentication is done via a web-based authorization flow, where the command will open a browser window in which you can login to your STACKIT account."),
+ Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
`Login to the STACKIT CLI. This command will open a browser window where you can login to your STACKIT account`,
"$ stackit auth login"),
),
- RunE: func(cmd *cobra.Command, args []string) error {
- err := auth.AuthorizeUser(p, false)
+ RunE: func(_ *cobra.Command, _ []string) error {
+ err := auth.AuthorizeUser(params.Printer, false)
if err != nil {
return fmt.Errorf("authorization failed: %w", err)
}
- p.Info("Successfully logged into STACKIT CLI.\n")
+ params.Printer.Outputln("Successfully logged into STACKIT CLI.\n")
+
return nil
},
}
diff --git a/internal/cmd/auth/logout/logout.go b/internal/cmd/auth/logout/logout.go
new file mode 100644
index 000000000..e5e4f6be8
--- /dev/null
+++ b/internal/cmd/auth/logout/logout.go
@@ -0,0 +1,37 @@
+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/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "logout",
+ Short: "Logs the user account out of the STACKIT CLI",
+ Long: "Logs the user account out of the STACKIT CLI.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Log out of the STACKIT CLI.`,
+ "$ stackit auth logout"),
+ ),
+ RunE: func(_ *cobra.Command, _ []string) error {
+ err := auth.LogoutUser()
+ if err != nil {
+ return fmt.Errorf("log out failed: %w", err)
+ }
+
+ params.Printer.Info("Successfully logged out of the STACKIT CLI.\n")
+ return nil
+ },
+ }
+ return cmd
+}
diff --git a/internal/cmd/beta/alb/alb.go b/internal/cmd/beta/alb/alb.go
new file mode 100644
index 000000000..62bd90d2b
--- /dev/null
+++ b/internal/cmd/beta/alb/alb.go
@@ -0,0 +1,47 @@
+package alb
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/list"
+ observabilitycredentials "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/observability-credentials"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/plans"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/pool"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb/quotas"
+ "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/types"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "alb",
+ Short: "Manages application loadbalancers",
+ Long: "Manage the lifecycle of application loadbalancers.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, params *types.CmdParams) {
+ cmd.AddCommand(
+ 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
new file mode 100644
index 000000000..2232d7aad
--- /dev/null
+++ b/internal/cmd/beta/alb/create/create.go
@@ -0,0 +1,172 @@
+package create
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "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"
+ "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/spinner"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb/wait"
+)
+
+const (
+ configurationFlag = "configuration"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Configuration *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates an application loadbalancer",
+ Long: "Creates an application loadbalancer.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create an application loadbalancer from a configuration file`,
+ "$ stackit beta alb create --configuration my-loadbalancer.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
+ }
+
+ 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 an application loadbalancer for project %q?", projectLabel)
+ err = params.Printer.PromptForConfirmation(prompt)
+ if err != nil {
+ return err
+ }
+
+ // Call API
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create application loadbalancer: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Creating loadbalancer")
+ _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for loadbalancer creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model, projectLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(configurationFlag, "c", "", "Filename of the input configuration file")
+ err := flags.MarkFlagsRequired(cmd, configurationFlag)
+ 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,
+ Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) (req alb.ApiCreateLoadBalancerRequest, err error) {
+ req = apiClient.CreateLoadBalancer(ctx, model.ProjectId, model.Region)
+ payload, err := readPayload(ctx, model)
+ if err != nil {
+ return req, err
+ }
+ return req.CreateLoadBalancerPayload(payload), nil
+}
+
+func readPayload(_ context.Context, model *inputModel) (payload alb.CreateLoadBalancerPayload, err error) {
+ if model.Configuration == nil {
+ return payload, fmt.Errorf("no configuration file defined")
+ }
+ file, err := os.Open(*model.Configuration)
+ if err != nil {
+ return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err)
+ }
+ defer file.Close() // nolint:errcheck // at this point close errors are not relevant anymore
+
+ if strings.HasSuffix(*model.Configuration, ".yaml") {
+ decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler())
+ if err := decoder.Decode(&payload); err != nil {
+ return payload, fmt.Errorf("cannot deserialize yaml configuration from %q: %w", *model.Configuration, err)
+ }
+ } else if strings.HasSuffix(*model.Configuration, ".json") {
+ decoder := json.NewDecoder(bufio.NewReader(file))
+ if err := decoder.Decode(&payload); err != nil {
+ return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err)
+ }
+ } else {
+ return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", *model.Configuration)
+ }
+
+ return payload, nil
+}
+
+func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *alb.LoadBalancer) error {
+ if resp == nil {
+ return fmt.Errorf("create loadbalancer response is empty")
+ }
+ 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
new file mode 100644
index 000000000..c99f4a859
--- /dev/null
+++ b/internal/cmd/beta/alb/create/create_test.go
@@ -0,0 +1,224 @@
+package create
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "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-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
+var testConfiguration []byte
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &alb.APIClient{}
+var testProjectId = uuid.NewString()
+var testRegion = "eu01"
+var testConfig = "testdata/testconfig.json"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ configurationFlag: testConfig,
+ 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,
+ },
+ Configuration: utils.Ptr(testConfig),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+func fixturePayload(mods ...func(payload *alb.CreateLoadBalancerPayload)) (payload alb.CreateLoadBalancerPayload) {
+ if err := json.Unmarshal(testConfiguration, &payload); err != nil {
+ log.Panicf("cannot deserialize test configuration: %v", err)
+ }
+ for _, f := range mods {
+ f(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiCreateLoadBalancerRequest)) alb.ApiCreateLoadBalancerRequest {
+ request := testClient.CreateLoadBalancer(testCtx, testProjectId, testRegion)
+
+ request = request.CreateLoadBalancerPayload(fixturePayload())
+ 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: "required fields only",
+ flagValues: map[string]string{
+ projectIdFlag: testProjectId,
+ configurationFlag: testConfig,
+ },
+ isValid: true,
+ expectedModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Configuration: &testConfig,
+ },
+ },
+ {
+ 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 alb.ApiCreateLoadBalancerRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "required fields only",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Configuration: &testConfig,
+ },
+ expectedRequest: testClient.
+ CreateLoadBalancer(testCtx, testProjectId, testRegion).
+ CreateLoadBalancerPayload(fixturePayload()),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient)
+ if err != nil {
+ t.Fatalf("canno create request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ projectLabel string
+ resp *alb.LoadBalancer
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty response as argument",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &alb.LoadBalancer{},
+ },
+ 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/alb/create/testdata/testconfig.json b/internal/cmd/beta/alb/create/testdata/testconfig.json
new file mode 100644
index 000000000..b81294c66
--- /dev/null
+++ b/internal/cmd/beta/alb/create/testdata/testconfig.json
@@ -0,0 +1,125 @@
+{
+ "externalAddress": "10.100.42.1",
+ "listeners": [
+ {
+ "displayName": "listener1",
+ "http": {},
+ "https": {
+ "certificateConfig": {
+ "certificateIds": [
+ "cert-1",
+ "cert-2",
+ "cert-3"
+ ]
+ }
+ },
+ "port": 443,
+ "protocol": "PROTOCOL_HTTPS",
+ "rules": [
+ {
+ "host": "front.facing.host",
+ "http": {
+ "subRules": [
+ {
+ "cookiePersistence": {
+ "name": "cookie1",
+ "ttl": "120s"
+ },
+ "headers": [
+ {
+ "name": "testheader1",
+ "exactMatch": "X-test-header1"
+ },
+ {
+ "name": "testheader2",
+ "exactMatch": "X-test-header2"
+ },
+ {
+ "name": "testheader3",
+ "exactMatch": "X-test-header3"
+ }
+ ],
+ "pathPrefix": "/foo",
+ "queryParameters": [
+ {
+ "name": "query-param",
+ "exactMatch": "q"
+ },
+ {
+ "name": "region",
+ "exactMatch": "region"
+ }
+ ],
+ "targetPool": "my-target-pool",
+ "webSocket": false
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ],
+ "name": "my-load-balancer",
+ "networks": [
+ {
+ "networkId": "00000000-0000-0000-0000-000000000000",
+ "role": "ROLE_LISTENERS_AND_TARGETS"
+ },
+ {
+ "networkId": "00000000-0000-0000-0000-000000000001",
+ "role": "ROLE_LISTENERS_AND_TARGETS"
+ }
+ ],
+ "options": {
+ "accessControl": {
+ "allowedSourceRanges": [
+ "192.168.42.0-192.168.42.10",
+ "192.168.54.0-192.168.54.10"
+ ]
+ },
+ "ephemeralAddress": true,
+ "observability": {
+ "logs": {
+ "credentialsRef": "my-credentials",
+ "pushUrl": "https://my.observability.host//loki/api/v1/push"
+ },
+ "metrics": {
+ "credentialsRef": "my-credentials",
+ "pushUrl": "https://my.observability.host///api/v1/receive"
+ }
+ },
+ "privateNetworkOnly": true
+ },
+ "planId": "p10",
+ "targetPools": [
+ {
+ "activeHealthCheck": {
+ "healthyThreshold": 3,
+ "httpHealthChecks": {
+ "okStatuses": [
+ "200",
+ "204"
+ ],
+ "path": "/health"
+ },
+ "interval": "10s",
+ "intervalJitter": "3s",
+ "timeout": "5s",
+ "unhealthyThreshold": 1
+ },
+ "name": "my-target-pool",
+ "targetPort": 5732,
+ "targets": [
+ {
+ "displayName": "my-target1",
+ "ip": "192.11.2.5"
+ }
+ ],
+ "tlsConfig": {
+ "customCa": "my.private.ca",
+ "enabled": true,
+ "skipCertificateValidation": false
+ }
+ }
+ ]
+}
diff --git a/internal/cmd/beta/alb/delete/delete.go b/internal/cmd/beta/alb/delete/delete.go
new file mode 100644
index 000000000..94b8163ee
--- /dev/null
+++ b/internal/cmd/beta/alb/delete/delete.go
@@ -0,0 +1,95 @@
+package delete
+
+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"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+const (
+ loadbalancerNameArg = "LOADBALANCER_NAME_ARG"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Name string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", loadbalancerNameArg),
+ Short: "Deletes an application loadbalancer",
+ Long: "Deletes an application loadbalancer.",
+ Args: args.SingleArg(loadbalancerNameArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete an application loadbalancer with name "my-load-balancer"`,
+ "$ stackit beta alb delete my-load-balancer",
+ ),
+ ),
+ 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 application loadbalancer %q for project %q?", model.Name, 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("Load balancer %q deleted.", model.Name)
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+
+ loadbalancerName := inputArgs[0]
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: loadbalancerName,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiDeleteLoadBalancerRequest {
+ return apiClient.DeleteLoadBalancer(ctx, model.ProjectId, model.Region, model.Name)
+}
diff --git a/internal/cmd/beta/alb/delete/delete_test.go b/internal/cmd/beta/alb/delete/delete_test.go
new file mode 100644
index 000000000..6c5290a52
--- /dev/null
+++ b/internal/cmd/beta/alb/delete/delete_test.go
@@ -0,0 +1,145 @@
+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/alb"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "test")
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testClient = &alb.APIClient{}
+ testLoadBalancerName = "my-test-loadbalancer"
+)
+
+func fixtureArgValues(mods ...func(argVales []string)) []string {
+ argVales := []string{
+ testLoadBalancerName,
+ }
+ 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,
+ globalflags.RegionFlag: testRegion,
+ }
+ 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,
+ Region: testRegion,
+ },
+ Name: testLoadBalancerName,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiDeleteLoadBalancerRequest)) alb.ApiDeleteLoadBalancerRequest {
+ request := testClient.DeleteLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancerName)
+ 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,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argsValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ argsValues: fixtureArgValues(),
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ }
+
+ 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 alb.ApiDeleteLoadBalancerRequest
+ }{
+ {
+ 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/alb/describe/describe.go b/internal/cmd/beta/alb/describe/describe.go
new file mode 100644
index 000000000..6d4cae785
--- /dev/null
+++ b/internal/cmd/beta/alb/describe/describe.go
@@ -0,0 +1,185 @@
+package describe
+
+import (
+ "context"
+ "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"
+ "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/alb/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+const (
+ loadbalancerNameArg = "LOADBALANCER_NAME_ARG"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Name string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", loadbalancerNameArg),
+ Short: "Describes an application loadbalancer",
+ Long: "Describes an application alb.",
+ Args: args.SingleArg(loadbalancerNameArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details about an application loadbalancer with name "my-load-balancer"`,
+ "$ stackit beta alb describe my-load-balancer",
+ ),
+ ),
+ 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 loadbalancer: %w", err)
+ }
+
+ if loadbalancer := resp; loadbalancer != nil {
+ return outputResult(params.Printer, model.OutputFormat, loadbalancer)
+ }
+ params.Printer.Outputln("No load balancer found.")
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+
+ loadbalancerName := inputArgs[0]
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: loadbalancerName,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiGetLoadBalancerRequest {
+ return apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, model.Name)
+}
+
+func outputResult(p *print.Printer, outputFormat string, loadbalancer *alb.LoadBalancer) error {
+ 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))
+ }
+
+ err := tables.DisplayTables(p, content)
+ if err != nil {
+ return fmt.Errorf("display output: %w", err)
+ }
+
+ return nil
+ })
+}
+
+func buildLoadBalancerTable(loadbalancer *alb.LoadBalancer) tables.Table {
+ acl := []string{}
+ privateAccessOnly := false
+ if loadbalancer.Options != nil {
+ if loadbalancer.Options.AccessControl != nil && loadbalancer.Options.AccessControl.AllowedSourceRanges != nil {
+ acl = *loadbalancer.Options.AccessControl.AllowedSourceRanges
+ }
+
+ if loadbalancer.Options.PrivateNetworkOnly != nil {
+ privateAccessOnly = *loadbalancer.Options.PrivateNetworkOnly
+ }
+ }
+
+ networkId := "-"
+ if loadbalancer.Networks != nil && len(*loadbalancer.Networks) > 0 {
+ networks := *loadbalancer.Networks
+ networkId = *networks[0].NetworkId
+ }
+
+ externalAddress := utils.PtrStringDefault(loadbalancer.ExternalAddress, "-")
+
+ errorDescriptions := []string{}
+ if loadbalancer.Errors != nil && len((*loadbalancer.Errors)) > 0 {
+ for _, err := range *loadbalancer.Errors {
+ errorDescriptions = append(errorDescriptions, *err.Description)
+ }
+ }
+
+ table := tables.NewTable()
+ table.SetTitle("Load Balancer")
+ table.AddRow("NAME", utils.PtrString(loadbalancer.Name))
+ table.AddSeparator()
+ table.AddRow("STATE", utils.PtrString(loadbalancer.Status))
+ table.AddSeparator()
+ if len(errorDescriptions) > 0 {
+ table.AddRow("ERROR DESCRIPTIONS", strings.Join(errorDescriptions, "\n"))
+ table.AddSeparator()
+ }
+ table.AddRow("PRIVATE ACCESS ONLY", privateAccessOnly)
+ table.AddSeparator()
+ table.AddRow("ATTACHED PUBLIC IP", externalAddress)
+ table.AddSeparator()
+ table.AddRow("ATTACHED NETWORK ID", networkId)
+ table.AddSeparator()
+ table.AddRow("ACL", acl)
+ return table
+}
+
+func buildListenersTable(listeners []alb.Listener) tables.Table {
+ table := tables.NewTable()
+ table.SetTitle("Listeners")
+ table.SetHeader("NAME", "PORT", "PROTOCOL", "TARGET POOL")
+ for i := range listeners {
+ listener := listeners[i]
+ table.AddRow(
+ utils.PtrString(listener.Name),
+ utils.PtrString(listener.Port),
+ utils.PtrString(listener.Protocol),
+ )
+ }
+ return table
+}
+
+func buildTargetPoolsTable(targetPools []alb.TargetPool) tables.Table {
+ table := tables.NewTable()
+ table.SetTitle("Target Pools")
+ table.SetHeader("NAME", "PORT", "TARGETS")
+ for _, targetPool := range targetPools {
+ table.AddRow(utils.PtrString(targetPool.Name), utils.PtrString(targetPool.TargetPort), len(*targetPool.Targets))
+ }
+ return table
+}
diff --git a/internal/cmd/beta/alb/describe/describe_test.go b/internal/cmd/beta/alb/describe/describe_test.go
new file mode 100644
index 000000000..9d79acbad
--- /dev/null
+++ b/internal/cmd/beta/alb/describe/describe_test.go
@@ -0,0 +1,180 @@
+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/alb"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "test")
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testClient = &alb.APIClient{}
+ testLoadBalancerName = "my-test-loadbalancer"
+)
+
+func fixtureArgValues(mods ...func(argVales []string)) []string {
+ argVales := []string{
+ testLoadBalancerName,
+ }
+ 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,
+ globalflags.RegionFlag: testRegion,
+ }
+ 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,
+ Region: testRegion,
+ },
+ Name: testLoadBalancerName,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiGetLoadBalancerRequest)) alb.ApiGetLoadBalancerRequest {
+ request := testClient.GetLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancerName)
+ 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,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argsValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ argsValues: fixtureArgValues(),
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ }
+
+ 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 alb.ApiGetLoadBalancerRequest
+ }{
+ {
+ 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)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ showOnlyPublicKey bool
+ response *alb.LoadBalancer
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ outputFormat: "",
+ showOnlyPublicKey: false,
+ response: &alb.LoadBalancer{},
+ },
+ 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.response); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/alb/list/list.go b/internal/cmd/beta/alb/list/list.go
new file mode 100644
index 000000000..bfeb711c6
--- /dev/null
+++ b/internal/cmd/beta/alb/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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+}
+
+const (
+ limitFlag = "limit"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists albs",
+ Long: "Lists application load balancers.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all load balancers`,
+ `$ stackit beta alb list`,
+ ),
+ examples.NewExample(
+ `List the first 10 application load balancers`,
+ `$ stackit beta alb 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 load balancerse: %w", err)
+ }
+ items := response.GetLoadBalancers()
+
+ // 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 *alb.APIClient) alb.ApiListLoadBalancersRequest {
+ request := apiClient.ListLoadBalancers(ctx, model.ProjectId, model.Region)
+
+ return request
+}
+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
+ }
+
+ table := tables.NewTable()
+ table.SetHeader("NAME", "EXTERNAL ADDRESS", "REGION", "STATUS", "VERSION", "ERRORS")
+ for i := range items {
+ item := &items[i]
+
+ var errNo int
+ if item.Errors != nil {
+ errNo = len(*item.Errors)
+ }
+ table.AddRow(utils.PtrString(item.Name),
+ utils.PtrString(item.ExternalAddress),
+ utils.PtrString(item.Region),
+ utils.PtrString(item.Status),
+ utils.PtrString(item.Version),
+ errNo,
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/beta/alb/list/list_test.go b/internal/cmd/beta/alb/list/list_test.go
new file mode 100644
index 000000000..b579d45a6
--- /dev/null
+++ b/internal/cmd/beta/alb/list/list_test.go
@@ -0,0 +1,179 @@
+package list
+
+import (
+ "context"
+ "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/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+type testCtxKey struct{}
+
+var (
+ 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 {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ 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{ProjectId: testProjectId, Region: testRegion, Verbosity: globalflags.VerbosityDefault},
+ Limit: utils.Ptr(testLimit),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiListLoadBalancersRequest)) alb.ApiListLoadBalancersRequest {
+ request := testClient.ListLoadBalancers(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,
+ },
+ }
+
+ 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 alb.ApiListLoadBalancersRequest
+ }{
+ {
+ 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
+ projectLabel string
+ items []alb.LoadBalancer
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ outputFormat: "",
+ items: []alb.LoadBalancer{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "output format json",
+ args: args{
+ outputFormat: print.JSONOutputFormat,
+ items: []alb.LoadBalancer{},
+ },
+ 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.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
new file mode 100644
index 000000000..69d21973b
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/add/add.go
@@ -0,0 +1,121 @@
+package add
+
+import (
+ "context"
+ "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"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+const (
+ usernameFlag = "username"
+ displaynameFlag = "displayname"
+ passwordFlag = "password"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Username *string
+ Displayname *string
+ Password *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "add",
+ Short: "Adds observability credentials to an application load balancer",
+ Long: "Adds observability credentials (username and password) to an application load balancer. The credentials can be for Observability or another monitoring tool.",
+ Args: cobra.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `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, args []string) error {
+ ctx := context.Background()
+
+ model, err := parseInput(params.Printer, cmd, args)
+ if err != nil {
+ return err
+ }
+
+ // Configure client
+ apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("add credential: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(usernameFlag, "u", "", "Username for the credentials")
+ cmd.Flags().StringP(displaynameFlag, "d", "", "Displayname for the credentials")
+ cmd.Flags().Var(flags.ReadFromFileFlag(), passwordFlag, `Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).`)
+
+ cobra.CheckErr(flags.MarkFlagsRequired(cmd, usernameFlag, displaynameFlag))
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Username: flags.FlagToStringPointer(p, cmd, usernameFlag),
+ Displayname: flags.FlagToStringPointer(p, cmd, displaynameFlag),
+ Password: flags.FlagToStringPointer(p, cmd, passwordFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiCreateCredentialsRequest {
+ req := apiClient.CreateCredentials(ctx, model.ProjectId, model.Region)
+ payload := alb.CreateCredentialsPayload{
+ DisplayName: model.Displayname,
+ Password: model.Password,
+ Username: model.Username,
+ }
+ return req.CreateCredentialsPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat string, item *alb.CreateCredentialsResponse) error {
+ if item == nil {
+ return fmt.Errorf("no credential found")
+ }
+
+ return p.OutputResult(outputFormat, item, func() error {
+ if item.Credential != nil {
+ p.Outputf("Created credential %s\n", utils.PtrString(item.Credential.CredentialsRef))
+ }
+ 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
new file mode 100644
index 000000000..de16544a6
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/add/add_test.go
@@ -0,0 +1,178 @@
+package add
+
+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/alb"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &alb.APIClient{}
+
+var (
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testDisplayname = "displayname"
+ testUsername = "testuser"
+ testPassword = "testpassword"
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ usernameFlag: testUsername,
+ displaynameFlag: testDisplayname,
+ passwordFlag: testPassword,
+ }
+ 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,
+ },
+ Username: &testUsername,
+ Displayname: &testDisplayname,
+ Password: &testPassword,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiCreateCredentialsRequest)) alb.ApiCreateCredentialsRequest {
+ request := testClient.CreateCredentials(testCtx, testProjectId, testRegion)
+ request = request.CreateCredentialsPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *alb.CreateCredentialsPayload)) alb.CreateCredentialsPayload {
+ payload := alb.CreateCredentialsPayload{
+ DisplayName: &testDisplayname,
+ Password: &testPassword,
+ Username: &testUsername,
+ }
+ 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,
+ },
+ }
+
+ 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 alb.ApiCreateCredentialsRequest
+ }{
+ {
+ 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(alb.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ item *alb.CreateCredentialsResponse
+ outputFormat string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ item: nil,
+ outputFormat: "",
+ },
+ wantErr: true,
+ },
+ {
+ name: "base",
+ args: args{
+ item: &alb.CreateCredentialsResponse{},
+ outputFormat: "",
+ },
+ 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.item); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/alb/observability-credentials/delete/delete.go b/internal/cmd/beta/alb/observability-credentials/delete/delete.go
new file mode 100644
index 000000000..274f977f2
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/delete/delete.go
@@ -0,0 +1,90 @@
+package delete
+
+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"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/alb/client"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+const (
+ credentialRefArg = "CREDENTIAL_REF" // nolint:gosec // false alert, these are not valid credentials
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ CredentialsRef string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", credentialRefArg),
+ Short: "Deletes credentials",
+ Long: "Deletes credentials.",
+ Args: args.SingleArg(credentialRefArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete credential with name "credential-12345"`,
+ "$ stackit beta alb observability-credentials delete credential-12345",
+ ),
+ ),
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ _, err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete credential: %w", err)
+ }
+
+ params.Printer.Info("Deleted credential %q\n", model.CredentialsRef)
+
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ credentialRef := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ CredentialsRef: credentialRef,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiDeleteCredentialsRequest {
+ return apiClient.DeleteCredentials(ctx, model.ProjectId, model.Region, model.CredentialsRef)
+}
diff --git a/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go b/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go
new file mode 100644
index 000000000..951846c66
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/delete/delete_test.go
@@ -0,0 +1,194 @@
+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/stackitcloud/stackit-sdk-go/services/alb"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "test")
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testClient = &alb.APIClient{}
+ testCredentialRef = "credential-12345"
+)
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testCredentialRef,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ CredentialsRef: testCredentialRef,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiDeleteCredentialsRequest)) alb.ApiDeleteCredentialsRequest {
+ request := testClient.DeleteCredentials(testCtx, testProjectId, testRegion, testCredentialRef)
+ 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{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: false,
+ },
+ {
+ description: "no args",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flags",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ }
+
+ 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 input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest alb.ApiDeleteCredentialsRequest
+ }{
+ {
+ 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/alb/observability-credentials/describe/describe.go b/internal/cmd/beta/alb/observability-credentials/describe/describe.go
new file mode 100644
index 000000000..fe370a10d
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/describe/describe.go
@@ -0,0 +1,105 @@
+package describe
+
+import (
+ "context"
+ "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"
+ "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/alb/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+const (
+ credentialRefArg = "CREDENTIAL_REF" // nolint:gosec // false alert, these are not valid credentials
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ CredentialRef string
+}
+
+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",
+ Long: "Describes observability credentials for the Application Load Balancer.",
+ Args: args.SingleArg(credentialRefArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details about credentials with name "credential-12345"`,
+ "$ stackit beta alb observability-credentials describe credential-12345",
+ ),
+ ),
+ 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 credentials: %w", err)
+ }
+
+ if credential := resp; credential != nil && credential.Credential != nil {
+ return outputResult(params.Printer, model.OutputFormat, *credential.Credential)
+ }
+ params.Printer.Outputln("No credentials found.")
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+
+ credentialRef := inputArgs[0]
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ CredentialRef: credentialRef,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiGetCredentialsRequest {
+ return apiClient.GetCredentials(ctx, model.ProjectId, model.Region, model.CredentialRef)
+}
+
+func outputResult(p *print.Printer, outputFormat string, response alb.CredentialsResponse) error {
+ return p.OutputResult(outputFormat, response, func() error {
+ table := tables.NewTable()
+ table.AddRow("CREDENTIAL REF", utils.PtrString(response.CredentialsRef))
+ table.AddSeparator()
+ table.AddRow("DISPLAYNAME", utils.PtrString(response.DisplayName))
+ table.AddSeparator()
+ table.AddRow("UESRNAME", utils.PtrString(response.Username))
+ table.AddSeparator()
+ table.AddRow("REGION", utils.PtrString(response.Region))
+ table.AddSeparator()
+
+ p.Outputln(table.Render())
+ 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
new file mode 100644
index 000000000..7846a6b21
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/describe/describe_test.go
@@ -0,0 +1,180 @@
+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/alb"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "test")
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testClient = &alb.APIClient{}
+ testCredentialRef = "credential-12345"
+)
+
+func fixtureArgValues(mods ...func(argVales []string)) []string {
+ argVales := []string{
+ testCredentialRef,
+ }
+ 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,
+ globalflags.RegionFlag: testRegion,
+ }
+ 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,
+ Region: testRegion,
+ },
+ CredentialRef: testCredentialRef,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiGetCredentialsRequest)) alb.ApiGetCredentialsRequest {
+ request := testClient.GetCredentials(testCtx, testProjectId, testRegion, testCredentialRef)
+ 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,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argsValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ argsValues: fixtureArgValues(),
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ }
+
+ 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 alb.ApiGetCredentialsRequest
+ }{
+ {
+ 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)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ showOnlyPublicKey bool
+ response alb.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ outputFormat: "",
+ showOnlyPublicKey: false,
+ response: alb.CredentialsResponse{},
+ },
+ 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.response); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/alb/observability-credentials/list/list.go b/internal/cmd/beta/alb/observability-credentials/list/list.go
new file mode 100644
index 000000000..5c44aae92
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/list/list.go
@@ -0,0 +1,137 @@
+package list
+
+import (
+ "context"
+ "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"
+ "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/alb/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+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 credentials",
+ Long: "Lists all credentials.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all credentials`,
+ "$ stackit beta alb observability-credentials list",
+ ),
+ examples.NewExample(
+ `Lists all credentials in JSON format`,
+ "$ stackit beta alb observability-credentials list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 credentials`,
+ "$ stackit beta alb observability-credentials 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("list credentials: %w", err)
+ }
+ items := resp.GetCredentials()
+
+ // Truncate output
+ 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().Int64(limitFlag, 0, "Number of credentials 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 buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) alb.ApiListCredentialsRequest {
+ req := apiClient.ListCredentials(ctx, model.ProjectId, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, items []alb.CredentialsResponse) error {
+ return p.OutputResult(outputFormat, items, func() error {
+ if len(items) == 0 {
+ p.Outputf("No credentials found\n")
+ return nil
+ }
+
+ table := tables.NewTable()
+ table.SetHeader("CREDENTIAL REF", "DISPLAYNAME", "USERNAME", "REGION")
+
+ for _, item := range items {
+ table.AddRow(
+ utils.PtrString(item.CredentialsRef),
+ utils.PtrString(item.DisplayName),
+ utils.PtrString(item.Username),
+ utils.PtrString(item.Region),
+ )
+ }
+
+ p.Outputln(table.Render())
+ 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
new file mode 100644
index 000000000..eacc42dea
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/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/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "test")
+ testClient = &alb.APIClient{}
+
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testLimit = int64(64)
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ limitFlag: strconv.FormatInt(testLimit, 10),
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ Limit: utils.Ptr(testLimit),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiListCredentialsRequest)) alb.ApiListCredentialsRequest {
+ request := testClient.ListCredentials(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{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.Limit = nil
+ }),
+ },
+ {
+ description: "withoutLimit",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, "limit")
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.Limit = nil
+ }),
+ },
+ {
+ description: "invalid limit 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[limitFlag] = "invalid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "invalid limit 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[limitFlag] = "0"
+ }),
+ isValid: false,
+ },
+ {
+ description: "label selector empty",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ 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 TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest alb.ApiListCredentialsRequest
+ }{
+ {
+ 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("request does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ response []alb.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ outputFormat: "",
+ response: []alb.CredentialsResponse{
+ {},
+ },
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ p := print.NewPrinter()
+ 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
new file mode 100644
index 000000000..0e05ae183
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/observability-credentials.go
@@ -0,0 +1,33 @@
+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/utils"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "observability-credentials",
+ Short: "Provides functionality for application loadbalancer credentials",
+ Long: "Provides functionality for application loadbalancer credentials",
+ Args: cobra.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+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
new file mode 100644
index 000000000..18cbf8eb2
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/update/update.go
@@ -0,0 +1,139 @@
+package update
+
+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"
+ "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/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+const (
+ usernameFlag = "username"
+ displaynameFlag = "displayname"
+ passwordFlag = "password"
+ credentialRefArg = "CREDENTIAL_REF_ARG" //nolint:gosec // false alert, these are not valid credentials
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Username *string
+ Displayname *string
+ Password *string
+ CredentialsRef *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", credentialRefArg),
+ Short: "Update credentials",
+ Long: "Update credentials.",
+ Args: args.SingleArg(credentialRefArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update the password of observability credentials of Application Load Balancer with credentials reference "credentials-xxx", by providing the path to a file with the new password as flag`,
+ "$ stackit beta alb observability-credentials update credentials-xxx --username user1 --displayname user1 --password @./new-password.txt"),
+ ),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+ model := parseInput(params.Printer, cmd, args)
+
+ // Configure API client
+ apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
+ if err != nil {
+ return err
+ }
+
+ req, err := buildRequest(ctx, &model, apiClient)
+ 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 credential %q for %q?", *model.CredentialsRef, projectLabel)
+ err = params.Printer.PromptForConfirmation(prompt)
+ if err != nil {
+ return fmt.Errorf("update credential: %w", err)
+ }
+
+ // Call API
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update credential: %w", err)
+ }
+ if resp == nil {
+ return fmt.Errorf("response is nil")
+ }
+
+ return outputResult(params.Printer, model, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(usernameFlag, "u", "", "Username for the credentials")
+ cmd.Flags().StringP(displaynameFlag, "d", "", "Displayname for the credentials")
+ cmd.Flags().Var(flags.ReadFromFileFlag(), passwordFlag, `Password. Can be a string or a file path, if prefixed with "@" (example: @./password.txt).`)
+
+ cobra.CheckErr(flags.MarkFlagsRequired(cmd, displaynameFlag, usernameFlag))
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) (req alb.ApiUpdateCredentialsRequest, err error) {
+ req = apiClient.UpdateCredentials(ctx, model.ProjectId, model.Region, *model.CredentialsRef)
+
+ payload := alb.UpdateCredentialsPayload{
+ DisplayName: model.Displayname,
+ Password: model.Password,
+ Username: model.Username,
+ }
+
+ if model.Displayname == nil && model.Username == nil {
+ return req, fmt.Errorf("no attribute to change passed")
+ }
+
+ return req.UpdateCredentialsPayload(payload), nil
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) inputModel {
+ model := inputModel{
+ GlobalFlagModel: globalflags.Parse(p, cmd),
+ Username: flags.FlagToStringPointer(p, cmd, usernameFlag),
+ Displayname: flags.FlagToStringPointer(p, cmd, displaynameFlag),
+ CredentialsRef: &inputArgs[0],
+ Password: flags.FlagToStringPointer(p, cmd, passwordFlag),
+ }
+
+ 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.OutputFormat
+ }
+ if response == nil {
+ return fmt.Errorf("no response passed")
+ }
+
+ return p.OutputResult(outputFormat, response.Credential, func() error {
+ p.Outputf("Updated credential %q\n", utils.PtrString(model.CredentialsRef))
+ 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
new file mode 100644
index 000000000..1697fc13a
--- /dev/null
+++ b/internal/cmd/beta/alb/observability-credentials/update/update_test.go
@@ -0,0 +1,230 @@
+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/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &alb.APIClient{}
+
+var (
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testCredentialRef = "credential-12345"
+ testDisplayname = "displayname"
+ testUsername = "testuser"
+ testPassword = "testpassword"
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ usernameFlag: testUsername,
+ displaynameFlag: testDisplayname,
+ passwordFlag: testPassword,
+ }
+ 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,
+ },
+ Username: &testUsername,
+ Displayname: &testDisplayname,
+ CredentialsRef: &testCredentialRef,
+ Password: &testPassword,
+ }
+ for _, mod := range mods {
+ mod(&model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiUpdateCredentialsRequest)) alb.ApiUpdateCredentialsRequest {
+ request := testClient.UpdateCredentials(testCtx, testProjectId, testRegion, testCredentialRef)
+ request = request.UpdateCredentialsPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *alb.UpdateCredentialsPayload)) alb.UpdateCredentialsPayload {
+ payload := alb.UpdateCredentialsPayload{
+ DisplayName: &testDisplayname,
+ Password: &testPassword,
+ Username: &testUsername,
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ args []string
+ isValid bool
+ expectedModel inputModel
+ }{
+ {
+ description: "base",
+ args: []string{testCredentialRef},
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ args: []string{testCredentialRef},
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ },
+ isValid: false,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Username = nil
+ model.Displayname = nil
+ }),
+ },
+ {
+ description: "required values",
+ args: []string{testCredentialRef},
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ usernameFlag: testUsername,
+ displaynameFlag: testDisplayname,
+ passwordFlag: testPassword,
+ },
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ }
+
+ 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)
+ }
+
+ model := parseInput(p, cmd, tt.args)
+
+ if !tt.isValid {
+ 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 alb.ApiUpdateCredentialsRequest
+ }{
+ {
+ description: "base",
+ 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("cannot build request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ cmp.AllowUnexported(alb.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ item *alb.UpdateCredentialsResponse
+ model inputModel
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ item: nil,
+ model: fixtureInputModel(),
+ },
+ 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.model, tt.args.item); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/alb/plans/plans.go b/internal/cmd/beta/alb/plans/plans.go
new file mode 100644
index 000000000..c38dd9b70
--- /dev/null
+++ b/internal/cmd/beta/alb/plans/plans.go
@@ -0,0 +1,119 @@
+package plans
+
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "plans",
+ Short: "Lists the application load balancer plans",
+ Long: "Lists the available application load balancer plans.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all application load balancer plans`,
+ `$ stackit beta alb plans`,
+ ),
+ ),
+ 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 plans: %w", err)
+ }
+ items := response.GetValidPlans()
+
+ return outputResult(params.Printer, model.OutputFormat, projectLabel, items)
+ },
+ }
+
+ 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 *alb.APIClient) alb.ApiListPlansRequest {
+ request := apiClient.ListPlans(ctx, model.Region)
+
+ return request
+}
+
+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
+ }
+
+ table := tables.NewTable()
+ table.SetHeader("PLAN ID", "NAME", "FLAVOR", "MAX CONNS", "DESCRIPTION")
+ for _, item := range items {
+ table.AddRow(utils.PtrString(item.PlanId),
+ utils.PtrString(item.Name),
+ utils.PtrString(item.FlavorName),
+ utils.PtrString(item.MaxConnections),
+ utils.Truncate(item.Description, 70),
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/beta/alb/plans/plans_test.go b/internal/cmd/beta/alb/plans/plans_test.go
new file mode 100644
index 000000000..c104680e5
--- /dev/null
+++ b/internal/cmd/beta/alb/plans/plans_test.go
@@ -0,0 +1,171 @@
+package plans
+
+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/alb"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &alb.APIClient{}
+ 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 *alb.ApiListPlansRequest)) alb.ApiListPlansRequest {
+ request := testClient.ListPlans(testCtx, 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,
+ },
+ }
+
+ 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 alb.ApiListPlansRequest
+ }{
+ {
+ 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
+ projectLabel string
+ items []alb.PlanDetails
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ outputFormat: "",
+ items: []alb.PlanDetails{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "output format json",
+ args: args{
+ outputFormat: print.JSONOutputFormat,
+ items: []alb.PlanDetails{},
+ },
+ 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.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
new file mode 100644
index 000000000..f83b7728b
--- /dev/null
+++ b/internal/cmd/beta/alb/pool/pool.go
@@ -0,0 +1,27 @@
+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/types"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "pool",
+ Short: "Manages target pools for application loadbalancers",
+ Long: "Manage the lifecycle of target pools for application loadbalancers.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, params *types.CmdParams) {
+ cmd.AddCommand(update.NewCmd(params))
+}
diff --git a/internal/cmd/beta/alb/pool/update/testdata/testconfig.json b/internal/cmd/beta/alb/pool/update/testdata/testconfig.json
new file mode 100644
index 000000000..0486c66f4
--- /dev/null
+++ b/internal/cmd/beta/alb/pool/update/testdata/testconfig.json
@@ -0,0 +1,28 @@
+{
+ "activeHealthCheck": {
+ "healthyThreshold": 1,
+ "httpHealthChecks": {
+ "okStatuses": [
+ "string"
+ ],
+ "path": "string"
+ },
+ "interval": "3s",
+ "intervalJitter": "3s",
+ "timeout": "3s",
+ "unhealthyThreshold": 1
+ },
+ "name": "my-target-pool",
+ "targetPort": 5732,
+ "targets": [
+ {
+ "displayName": "my-target",
+ "ip": "192.0.2.5"
+ }
+ ],
+ "tlsConfig": {
+ "customCa": "string",
+ "enabled": true,
+ "skipCertificateValidation": true
+ }
+}
\ No newline at end of file
diff --git a/internal/cmd/beta/alb/pool/update/update.go b/internal/cmd/beta/alb/pool/update/update.go
new file mode 100644
index 000000000..a98d5f822
--- /dev/null
+++ b/internal/cmd/beta/alb/pool/update/update.go
@@ -0,0 +1,166 @@
+package update
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "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"
+ "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/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+const (
+ configurationFlag = "configuration"
+ albNameFlag = "name"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Configuration *string
+ AlbName *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "update",
+ Short: "Updates an application target pool",
+ Long: "Updates an application target pool.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `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, 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 update an application target pool for project %q?", projectLabel)
+ err = params.Printer.PromptForConfirmation(prompt)
+ if err != nil {
+ return err
+ }
+
+ // Call API
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update application target pool: %w", err)
+ }
+
+ return outputResult(params.Printer, model, projectLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(configurationFlag, "c", "", "Filename of the input configuration file")
+ cmd.Flags().StringP(albNameFlag, "n", "", "Name of the target pool name to update")
+ err := flags.MarkFlagsRequired(cmd, configurationFlag, albNameFlag)
+ 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,
+ Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag),
+ AlbName: flags.FlagToStringPointer(p, cmd, albNameFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) (req alb.ApiUpdateTargetPoolRequest, err error) {
+ payload, err := readPayload(ctx, model)
+ if err != nil {
+ return req, err
+ }
+ if payload.Name == nil {
+ return req, fmt.Errorf("update target pool: no poolname provided")
+ }
+ req = apiClient.UpdateTargetPool(ctx, model.ProjectId, model.Region, *model.AlbName, *payload.Name)
+ return req.UpdateTargetPoolPayload(payload), nil
+}
+
+func readPayload(_ context.Context, model *inputModel) (payload alb.UpdateTargetPoolPayload, err error) {
+ if model.Configuration == nil {
+ return payload, fmt.Errorf("no configuration file defined")
+ }
+ file, err := os.Open(*model.Configuration)
+ if err != nil {
+ return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err)
+ }
+ defer file.Close() // nolint:errcheck // at this point close errors are not relevant anymore
+
+ if strings.HasSuffix(*model.Configuration, ".yaml") {
+ decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler())
+ if err := decoder.Decode(&payload); err != nil {
+ return payload, fmt.Errorf("cannot deserialize yaml configuration from %q: %w", *model.Configuration, err)
+ }
+ } else if strings.HasSuffix(*model.Configuration, ".json") {
+ decoder := json.NewDecoder(bufio.NewReader(file))
+ if err := decoder.Decode(&payload); err != nil {
+ return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err)
+ }
+ } else {
+ return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", *model.Configuration)
+ }
+
+ return payload, nil
+}
+
+func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *alb.TargetPool) error {
+ if resp == nil {
+ return fmt.Errorf("update target pool response is empty")
+ }
+ 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
new file mode 100644
index 000000000..771cf6ce1
--- /dev/null
+++ b/internal/cmd/beta/alb/pool/update/update_test.go
@@ -0,0 +1,233 @@
+package update
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "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-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
+var testConfiguration []byte
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &alb.APIClient{}
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testLoadBalancer = "my-load-balancer"
+ testPool = "my-target-pool"
+ testConfig = "testdata/testconfig.json"
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ configurationFlag: testConfig,
+ globalflags.RegionFlag: testRegion,
+ albNameFlag: testLoadBalancer,
+ }
+ 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,
+ },
+ Configuration: utils.Ptr(testConfig),
+ AlbName: &testLoadBalancer,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+func fixturePayload(mods ...func(payload *alb.UpdateTargetPoolPayload)) (payload alb.UpdateTargetPoolPayload) {
+ if err := json.Unmarshal(testConfiguration, &payload); err != nil {
+ log.Panicf("cannot deserialize test configuration: %v", err)
+ }
+ for _, f := range mods {
+ f(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiUpdateTargetPoolRequest)) alb.ApiUpdateTargetPoolRequest {
+ request := testClient.UpdateTargetPool(testCtx, testProjectId, testRegion, testLoadBalancer, testPool)
+
+ request = request.UpdateTargetPoolPayload(fixturePayload())
+ 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: "required fields only",
+ flagValues: map[string]string{
+ projectIdFlag: testProjectId,
+ configurationFlag: testConfig,
+ albNameFlag: testLoadBalancer,
+ },
+ isValid: true,
+ expectedModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Configuration: &testConfig,
+ AlbName: &testLoadBalancer,
+ },
+ },
+ {
+ 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 alb.ApiUpdateTargetPoolRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "required fields only",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Configuration: &testConfig,
+ AlbName: &testLoadBalancer,
+ },
+ expectedRequest: testClient.
+ UpdateTargetPool(testCtx, testProjectId, testRegion, testLoadBalancer, testPool).
+ UpdateTargetPoolPayload(fixturePayload()),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient)
+ if err != nil {
+ t.Fatalf("cannot create request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ projectLabel string
+ resp *alb.TargetPool
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty response as argument",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &alb.TargetPool{},
+ },
+ 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/alb/quotas/quotas.go b/internal/cmd/beta/alb/quotas/quotas.go
new file mode 100644
index 000000000..29f1c3bfd
--- /dev/null
+++ b/internal/cmd/beta/alb/quotas/quotas.go
@@ -0,0 +1,107 @@
+package quotas
+
+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/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(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "quotas",
+ Short: "Shows the application load balancer quotas",
+ Long: "Shows the application load balancer quotas for the application load balancers.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all application load balancer quotas`,
+ `$ stackit beta alb quotas`,
+ ),
+ ),
+ 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)
+
+ response, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("get quotas: %w", err)
+ }
+
+ if response == nil {
+ params.Printer.Outputln("no quotas found")
+ return nil
+ }
+
+ if err := outputResult(params.Printer, model.OutputFormat, *response); err != nil {
+ return fmt.Errorf("output loadbalancers: %w", err)
+ }
+
+ 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 *alb.APIClient) alb.ApiGetQuotaRequest {
+ request := apiClient.GetQuota(ctx, model.ProjectId, model.Region)
+
+ return request
+}
+
+func outputResult(p *print.Printer, outputFormat string, response alb.GetQuotaResponse) error {
+ return p.OutputResult(outputFormat, response, func() error {
+ table := tables.NewTable()
+ table.AddRow("REGION", utils.PtrString(response.Region))
+ table.AddSeparator()
+ table.AddRow("MAX LOADBALANCERS", utils.PtrString(response.MaxLoadBalancers))
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/beta/alb/quotas/quotas_test.go b/internal/cmd/beta/alb/quotas/quotas_test.go
new file mode 100644
index 000000000..80ee324f6
--- /dev/null
+++ b/internal/cmd/beta/alb/quotas/quotas_test.go
@@ -0,0 +1,169 @@
+package quotas
+
+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/alb"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &alb.APIClient{}
+ testProjectId = uuid.NewString()
+ 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 *alb.ApiGetQuotaRequest)) alb.ApiGetQuotaRequest {
+ request := testClient.GetQuota(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,
+ },
+ }
+
+ 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 alb.ApiGetQuotaRequest
+ }{
+ {
+ 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
+ response alb.GetQuotaResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ outputFormat: "",
+ response: alb.GetQuotaResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "output format json",
+ args: args{
+ outputFormat: print.JSONOutputFormat,
+ response: alb.GetQuotaResponse{},
+ },
+ 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.response); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/alb/template/template-alb.yaml b/internal/cmd/beta/alb/template/template-alb.yaml
new file mode 100644
index 000000000..541aaf3de
--- /dev/null
+++ b/internal/cmd/beta/alb/template/template-alb.yaml
@@ -0,0 +1,91 @@
+name: my-load-balancer
+# public ip, must be removed when ephemeral address option is true
+externalAddress: 123.123.123.123
+
+# public listening interfaces of the loadbalancer
+listeners:
+ - displayName: listener1
+ # for plain http the https block must be removed
+ # http: {}
+ https:
+ certificateConfig:
+ certificateIds:
+ - cert-1
+ - cert-2
+ - cert-3
+ port: 443
+ # protocal may be PROTOCOL_HTTPS or PROTOCOL_HTTP
+ protocol: PROTOCOL_HTTPS
+ rules:
+ # fqdn of the virtual host of the load balancer
+ - host: front.facing.host
+ http:
+ subRules:
+ - cookiePersistence:
+ name: cookie1
+ ttl: 120s
+ headers:
+ - name: testheader1
+ exactMatch: X-test-header1
+ - name: testheader2
+ exactMatch: X-test-header2
+ - name: testheader3
+ exactMatch: X-test-header3
+ pathPrefix: /foo
+ queryParameters:
+ - name: query-param
+ exactMatch: q
+ - name: region
+ exactMatch: region
+ targetPool: my-target-pool
+ webSocket: false
+networks:
+ - networkId: 00000000-0000-0000-0000-000000000000
+ role: ROLE_LISTENERS_AND_TARGETS
+ - networkId: 00000000-0000-0000-0000-000000000001
+ role: ROLE_LISTENERS_AND_TARGETS
+options:
+ accessControl:
+ # which host may access the loadbalancer in prefix notation
+ allowedSourceRanges:
+ - 10.100.42.0/24
+ ephemeralAddress: true
+ privateNetworkOnly: true
+
+ # Enable observability features. Appropriate credentials must be made
+ # available using the credentials endpoint
+ # observability:
+ # logs:
+ # credentialsRef: my-credentials
+ # pushUrl: https://my.observability.host//loki/api/v1/push
+ # metrics:
+ # credentialsRef: my-credentials
+ # pushUrl: https://my.observability.host///api/v1/receive
+
+planId: p10
+
+# definition of the backend servers
+targetPools:
+ - name: my-target-pool
+ activeHealthCheck:
+ healthyThreshold: 3
+ httpHealthChecks:
+ okStatuses:
+ - "200"
+ path: /health
+ interval: 10s
+ intervalJitter: 3s
+ timeout: 5s
+ unhealthyThreshold: 1
+ targetPort: 5732
+ targets:
+ # configuration of the backend servers
+ - displayName: my-target1
+ ip: 192.11.2.5
+ # if the backend servers must be accessed via TLS the following block
+ # allows defining the TLS configuration
+ # tlsConfig:
+ # # A PEM and base64 encoded certificate
+ # customCa: LS0t...
+ # enabled: true
+ # skipCertificateValidation: false
diff --git a/internal/cmd/beta/alb/template/template-pool.yaml b/internal/cmd/beta/alb/template/template-pool.yaml
new file mode 100644
index 000000000..d3a56af9b
--- /dev/null
+++ b/internal/cmd/beta/alb/template/template-pool.yaml
@@ -0,0 +1,23 @@
+activeHealthCheck:
+ healthyThreshold: 1
+ httpHealthChecks:
+ okStatuses:
+ - "200"
+ path: /health
+ interval: 3s
+ intervalJitter: 3s
+ timeout: 3s
+ unhealthyThreshold: 1
+name: my-target-pool
+targetPort: 4000
+targets:
+ - displayName: my-target
+ ip: 10.0.1.155
+ # if the backend servers must be accessed via TLS the following block
+ # allows defining the TLS configuration
+ # tlsConfig:
+ # # A PEM and base64 encoded certificate
+ # customCa: LS0...
+ # enabled: true
+ # skipCertificateValidation: false
+
diff --git a/internal/cmd/beta/alb/template/template.go b/internal/cmd/beta/alb/template/template.go
new file mode 100644
index 000000000..67819b173
--- /dev/null
+++ b/internal/cmd/beta/alb/template/template.go
@@ -0,0 +1,118 @@
+package template
+
+import (
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+)
+
+const (
+ formatFlag = "format"
+ typeFlag = "type"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Format *string
+ Type *string
+}
+
+var (
+ //go:embed template-alb.yaml
+ templateAlb string
+ //go:embed template-pool.yaml
+ templatePool string
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "template",
+ Short: "creates configuration templates to use for resource creation",
+ Long: "creates a json or yaml template file for creating/updating an application loadbalancer or target pool.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a yaml template`,
+ `$ stackit beta alb template --format=yaml --type alb`,
+ ),
+ examples.NewExample(
+ `Create a json template`,
+ `$ stackit beta alb template --format=json --type pool`,
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ model, err := parseInput(params.Printer, cmd, args)
+ if err != nil {
+ return err
+ }
+
+ var (
+ template string
+ target any
+ )
+ if model.Type != nil && *model.Type == "pool" {
+ template = templatePool
+ target = alb.CreateLoadBalancerPayload{}
+ } else if model.Type == nil || *model.Type == "alb" {
+ template = templateAlb
+ target = alb.UpdateTargetPoolPayload{}
+ } else {
+ return fmt.Errorf("invalid type %q", utils.PtrString(model.Type))
+ }
+
+ if model.Format == nil || *model.Format == "yaml" {
+ 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)
+ }
+ encoder := json.NewEncoder(os.Stdout)
+ if err := encoder.Encode(target); err != nil {
+ return fmt.Errorf("cannot marshal template to yaml: %w", err)
+ }
+ } else {
+ return fmt.Errorf("invalid format %q defined. Must be 'json' or 'yaml'", *model.Format)
+ }
+
+ return nil
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.EnumFlag(true, "json", "json", "yaml"), formatFlag, "f", "Defines the output format ('yaml' or 'json'), default is 'json'")
+ 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, _ []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Format: flags.FlagToStringPointer(p, cmd, formatFlag),
+ Type: flags.FlagToStringPointer(p, cmd, typeFlag),
+ }
+
+ 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
new file mode 100644
index 000000000..7f73d3f7d
--- /dev/null
+++ b/internal/cmd/beta/alb/template/template_test.go
@@ -0,0 +1,143 @@
+package template
+
+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/uuid"
+)
+
+var (
+ testProjectId = uuid.NewString()
+ 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 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: "alb with yaml",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[formatFlag] = "yaml"
+ flagValues[typeFlag] = "alb"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Format = utils.Ptr("yaml")
+ model.Type = utils.Ptr("alb")
+ }),
+ }, {
+ description: "alb with yaml",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[formatFlag] = "yaml"
+ flagValues[typeFlag] = "alb"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Format = utils.Ptr("yaml")
+ model.Type = utils.Ptr("alb")
+ }),
+ }, {
+ description: "alb with json",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[formatFlag] = "json"
+ flagValues[typeFlag] = "alb"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Format = utils.Ptr("json")
+ model.Type = utils.Ptr("alb")
+ }),
+ }, {
+ description: "pool with yaml",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[formatFlag] = "yaml"
+ flagValues[typeFlag] = "pool"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Format = utils.Ptr("yaml")
+ model.Type = utils.Ptr("pool")
+ }),
+ },
+ {
+ description: "pool with json",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[formatFlag] = "json"
+ flagValues[typeFlag] = "pool"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Format = utils.Ptr("json")
+ model.Type = utils.Ptr("pool")
+ }),
+ },
+ }
+
+ 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/beta/alb/update/testdata/testconfig.json b/internal/cmd/beta/alb/update/testdata/testconfig.json
new file mode 100644
index 000000000..b81294c66
--- /dev/null
+++ b/internal/cmd/beta/alb/update/testdata/testconfig.json
@@ -0,0 +1,125 @@
+{
+ "externalAddress": "10.100.42.1",
+ "listeners": [
+ {
+ "displayName": "listener1",
+ "http": {},
+ "https": {
+ "certificateConfig": {
+ "certificateIds": [
+ "cert-1",
+ "cert-2",
+ "cert-3"
+ ]
+ }
+ },
+ "port": 443,
+ "protocol": "PROTOCOL_HTTPS",
+ "rules": [
+ {
+ "host": "front.facing.host",
+ "http": {
+ "subRules": [
+ {
+ "cookiePersistence": {
+ "name": "cookie1",
+ "ttl": "120s"
+ },
+ "headers": [
+ {
+ "name": "testheader1",
+ "exactMatch": "X-test-header1"
+ },
+ {
+ "name": "testheader2",
+ "exactMatch": "X-test-header2"
+ },
+ {
+ "name": "testheader3",
+ "exactMatch": "X-test-header3"
+ }
+ ],
+ "pathPrefix": "/foo",
+ "queryParameters": [
+ {
+ "name": "query-param",
+ "exactMatch": "q"
+ },
+ {
+ "name": "region",
+ "exactMatch": "region"
+ }
+ ],
+ "targetPool": "my-target-pool",
+ "webSocket": false
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ],
+ "name": "my-load-balancer",
+ "networks": [
+ {
+ "networkId": "00000000-0000-0000-0000-000000000000",
+ "role": "ROLE_LISTENERS_AND_TARGETS"
+ },
+ {
+ "networkId": "00000000-0000-0000-0000-000000000001",
+ "role": "ROLE_LISTENERS_AND_TARGETS"
+ }
+ ],
+ "options": {
+ "accessControl": {
+ "allowedSourceRanges": [
+ "192.168.42.0-192.168.42.10",
+ "192.168.54.0-192.168.54.10"
+ ]
+ },
+ "ephemeralAddress": true,
+ "observability": {
+ "logs": {
+ "credentialsRef": "my-credentials",
+ "pushUrl": "https://my.observability.host//loki/api/v1/push"
+ },
+ "metrics": {
+ "credentialsRef": "my-credentials",
+ "pushUrl": "https://my.observability.host///api/v1/receive"
+ }
+ },
+ "privateNetworkOnly": true
+ },
+ "planId": "p10",
+ "targetPools": [
+ {
+ "activeHealthCheck": {
+ "healthyThreshold": 3,
+ "httpHealthChecks": {
+ "okStatuses": [
+ "200",
+ "204"
+ ],
+ "path": "/health"
+ },
+ "interval": "10s",
+ "intervalJitter": "3s",
+ "timeout": "5s",
+ "unhealthyThreshold": 1
+ },
+ "name": "my-target-pool",
+ "targetPort": 5732,
+ "targets": [
+ {
+ "displayName": "my-target1",
+ "ip": "192.11.2.5"
+ }
+ ],
+ "tlsConfig": {
+ "customCa": "my.private.ca",
+ "enabled": true,
+ "skipCertificateValidation": false
+ }
+ }
+ ]
+}
diff --git a/internal/cmd/beta/alb/update/update.go b/internal/cmd/beta/alb/update/update.go
new file mode 100644
index 000000000..1221cba4f
--- /dev/null
+++ b/internal/cmd/beta/alb/update/update.go
@@ -0,0 +1,202 @@
+package update
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "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"
+ "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/spinner"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb"
+ "github.com/stackitcloud/stackit-sdk-go/services/alb/wait"
+)
+
+const (
+ configurationFlag = "configuration"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Configuration *string
+ Version *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "update",
+ Short: "Updates an application loadbalancer",
+ Long: "Updates an application loadbalancer.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Update an application loadbalancer from a configuration file`,
+ "$ stackit beta alb update --configuration my-loadbalancer.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
+ }
+
+ 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 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
+ model.Version, err = getCurrentAlbVersion(ctx, apiClient, model)
+ if err != nil {
+ return err
+ }
+ // Call API
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update application loadbalancer: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("updating loadbalancer")
+ _, err = wait.CreateOrUpdateLoadbalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Name).
+ WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for loadbalancer update: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model, projectLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(configurationFlag, "c", "", "Filename of the input configuration file")
+ err := flags.MarkFlagsRequired(cmd, configurationFlag)
+ 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,
+ Configuration: flags.FlagToStringPointer(p, cmd, configurationFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func getCurrentAlbVersion(ctx context.Context, apiClient *alb.APIClient, model *inputModel) (*string, error) {
+ // use the configuration file to find the name of the loadbalancer
+ updatePayload, err := readPayload(ctx, model)
+ if err != nil {
+ return nil, err
+ }
+ if updatePayload.Name == nil {
+ return nil, fmt.Errorf("no name found in configuration")
+ }
+ if err != nil {
+ return nil, err
+ }
+ resp, err := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, *updatePayload.Name).Execute()
+ if err != nil {
+ return nil, err
+ }
+ return resp.Version, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *alb.APIClient) (req alb.ApiUpdateLoadBalancerRequest, err error) {
+ payload, err := readPayload(ctx, model)
+ if err != nil {
+ return req, err
+ }
+ if payload.Name == nil {
+ return req, fmt.Errorf("no name found in loadbalancer configuration")
+ }
+ payload.Version = model.Version
+ req = apiClient.UpdateLoadBalancer(ctx, model.ProjectId, model.Region, *payload.Name)
+ return req.UpdateLoadBalancerPayload(payload), nil
+}
+
+func readPayload(_ context.Context, model *inputModel) (payload alb.UpdateLoadBalancerPayload, err error) {
+ if model.Configuration == nil {
+ return payload, fmt.Errorf("no configuration file defined")
+ }
+ file, err := os.Open(*model.Configuration)
+ if err != nil {
+ return payload, fmt.Errorf("cannot open configuration file %q: %w", *model.Configuration, err)
+ }
+ defer file.Close() // nolint:errcheck // at this point close errors are not relevant anymore
+
+ if strings.HasSuffix(*model.Configuration, ".yaml") {
+ decoder := yaml.NewDecoder(bufio.NewReader(file), yaml.UseJSONUnmarshaler())
+ if err := decoder.Decode(&payload); err != nil {
+ return payload, fmt.Errorf("cannot deserialize yaml configuration from %q: %w", *model.Configuration, err)
+ }
+ } else if strings.HasSuffix(*model.Configuration, ".json") {
+ decoder := json.NewDecoder(bufio.NewReader(file))
+ if err := decoder.Decode(&payload); err != nil {
+ return payload, fmt.Errorf("cannot deserialize json configuration from %q: %w", *model.Configuration, err)
+ }
+ } else {
+ return payload, fmt.Errorf("cannot determine configuration fileformat of %q by extension. Must be '.json' or '.yaml'", *model.Configuration)
+ }
+
+ return payload, nil
+}
+
+func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *alb.LoadBalancer) error {
+ if resp == nil {
+ return fmt.Errorf("update loadbalancer response is empty")
+ }
+ 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
new file mode 100644
index 000000000..d4ccb0788
--- /dev/null
+++ b/internal/cmd/beta/alb/update/update_test.go
@@ -0,0 +1,227 @@
+package update
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "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-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
+var testConfiguration []byte
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &alb.APIClient{}
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+ testLoadBalancer = "my-load-balancer"
+ testConfig = "testdata/testconfig.json"
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ configurationFlag: testConfig,
+ 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,
+ },
+ Configuration: utils.Ptr(testConfig),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+func fixturePayload(mods ...func(payload *alb.UpdateLoadBalancerPayload)) (payload alb.UpdateLoadBalancerPayload) {
+ if err := json.Unmarshal(testConfiguration, &payload); err != nil {
+ log.Panicf("cannot deserialize test configuration: %v", err)
+ }
+ for _, f := range mods {
+ f(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *alb.ApiUpdateLoadBalancerRequest)) alb.ApiUpdateLoadBalancerRequest {
+ request := testClient.UpdateLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancer)
+
+ request = request.UpdateLoadBalancerPayload(fixturePayload())
+ 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: "required fields only",
+ flagValues: map[string]string{
+ projectIdFlag: testProjectId,
+ configurationFlag: testConfig,
+ },
+ isValid: true,
+ expectedModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Configuration: &testConfig,
+ },
+ },
+ {
+ 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 alb.ApiUpdateLoadBalancerRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "required fields only",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Configuration: &testConfig,
+ },
+ expectedRequest: testClient.
+ UpdateLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancer).
+ UpdateLoadBalancerPayload(fixturePayload()),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient)
+ if err != nil {
+ t.Fatalf("cannot create request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ projectLabel string
+ resp *alb.LoadBalancer
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty response as argument",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &alb.LoadBalancer{},
+ },
+ 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/beta.go b/internal/cmd/beta/beta.go
index ed6873b0e..bf3bd4129 100644
--- a/internal/cmd/beta/beta.go
+++ b/internal/cmd/beta/beta.go
@@ -3,16 +3,24 @@ 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/kms"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/logs"
+ "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",
@@ -30,10 +38,17 @@ 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))
+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(kms.NewCmd(params))
+ cmd.AddCommand(logs.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..257633ef0
--- /dev/null
+++ b/internal/cmd/beta/cdn/cdn.go
@@ -0,0 +1,25 @@
+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..19c5d3ca8
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/create/create.go
@@ -0,0 +1,340 @@
+package create
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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..4276f34a7
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/create/create_test.go
@@ -0,0 +1,542 @@
+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"
+ "github.com/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"
+ sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+ "k8s.io/utils/ptr"
+)
+
+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..78baebdc4
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/delete/delete.go
@@ -0,0 +1,92 @@
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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..03ec87f46
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/delete/delete_test.go
@@ -0,0 +1,130 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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..9d4897f72
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/describe/describe.go
@@ -0,0 +1,219 @@
+package describe
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "strings"
+
+ "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/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"
+ sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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..78037f8d1
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/describe/describe_test.go
@@ -0,0 +1,409 @@
+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-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/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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(`
+[106;30;1m Distribution [0m
+ 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(`
+[106;30;1m Distribution [0m
+ 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(`
+[106;30;1m Distribution [0m
+ 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
+
+
+[106;30;1m WAF [0m
+ 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..defb471c2
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/distribution.go
@@ -0,0 +1,32 @@
+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..aae2b0db0
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/list/list.go
@@ -0,0 +1,175 @@
+package list
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "strings"
+
+ "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/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"
+ sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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..8e5d71aa9
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/list/list_test.go
@@ -0,0 +1,471 @@
+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"
+ "github.com/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"
+ sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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..0c6662e60
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/update/update.go
@@ -0,0 +1,337 @@
+package update
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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..bf3dd11f5
--- /dev/null
+++ b/internal/cmd/beta/cdn/distribution/update/update_test.go
@@ -0,0 +1,365 @@
+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-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/stackitcloud/stackit-sdk-go/services/cdn"
+ "k8s.io/utils/ptr"
+)
+
+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..11d1b1e16
--- /dev/null
+++ b/internal/cmd/beta/edge/edge.go
@@ -0,0 +1,34 @@
+// 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..72f7ec2d2
--- /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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge/wait"
+)
+
+// 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Creating instance")
+ // The waiter handler needs a concrete client type. We can safely cast here as the real implementation will always match.
+ client, ok := apiClient.(*edge.APIClient)
+ if !ok {
+ return fmt.Errorf("failed to configure API client")
+ }
+ _, err = wait.CreateOrUpdateInstanceWaitHandler(ctx, client, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx)
+
+ if err != nil {
+ return fmt.Errorf("wait for edge instance creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ // 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..e4ce1482d
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/create/create_test.go
@@ -0,0 +1,419 @@
+// 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"
+ 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"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..d6650e7e6
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/delete/delete.go
@@ -0,0 +1,241 @@
+// 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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge/wait"
+)
+
+// 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting instance")
+ // Determine identifier and waiter to use
+ waiterFactory, err := getWaiterFactory(ctx, model)
+ if err != nil {
+ return err
+ }
+ // The waiter factory needs a concrete client type. We can safely cast here as the real implementation will always match.
+ client, ok := apiClient.(*edge.APIClient)
+ if !ok {
+ return fmt.Errorf("failed to configure API client")
+ }
+ waiter := waiterFactory(client)
+
+ if _, err = waiter.WaitWithContext(ctx); err != nil {
+ return fmt.Errorf("wait for edge instance deletion: %w", err)
+ }
+ operationState = "Deleted"
+ s.Stop()
+ }
+
+ 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..c15a3e7d7
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/delete/delete_test.go
@@ -0,0 +1,557 @@
+// 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"
+ 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-sdk-go/core/oapierror"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..5a7d85ed6
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/describe/describe.go
@@ -0,0 +1,203 @@
+// 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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..913a9c221
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/describe/describe_test.go
@@ -0,0 +1,575 @@
+// 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"
+ 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"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..6a589bfdd
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..3b5adc6dc
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/list/list_test.go
@@ -0,0 +1,460 @@
+// 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"
+ 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"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..28ec3437a
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/update/update.go
@@ -0,0 +1,281 @@
+// 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-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge/wait"
+)
+
+// 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
+ s := spinner.New(params.Printer)
+ s.Start("Updating instance")
+ // Determine identifier and waiter to use
+ waiterFactory, err := getWaiterFactory(ctx, model)
+ if err != nil {
+ return err
+ }
+ // The waiter handler needs a concrete client type. We can safely cast here as the real implementation will always match.
+ client, ok := apiClient.(*edge.APIClient)
+ if !ok {
+ return fmt.Errorf("failed to configure API client")
+ }
+ waiter := waiterFactory(client)
+
+ if _, err = waiter.WaitWithContext(ctx); err != nil {
+ return fmt.Errorf("wait for edge instance update: %w", err)
+ }
+ operationState = "Updated"
+ s.Stop()
+ }
+
+ 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..61e78d50d
--- /dev/null
+++ b/internal/cmd/beta/edge/instance/update/update_test.go
@@ -0,0 +1,541 @@
+// 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"
+ 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-sdk-go/core/oapierror"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..b22b7a1b3
--- /dev/null
+++ b/internal/cmd/beta/edge/kubeconfig/create/create.go
@@ -0,0 +1,396 @@
+// 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-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"
+ "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"
+)
+
+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..1a353e303
--- /dev/null
+++ b/internal/cmd/beta/edge/kubeconfig/create/create_test.go
@@ -0,0 +1,820 @@
+// 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"
+ 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"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..ad4c3e178
--- /dev/null
+++ b/internal/cmd/beta/edge/plans/list/list.go
@@ -0,0 +1,193 @@
+// 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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+// 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..d2fcb595f
--- /dev/null
+++ b/internal/cmd/beta/edge/plans/list/list_test.go
@@ -0,0 +1,450 @@
+// 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"
+ 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"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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..f28e196ab
--- /dev/null
+++ b/internal/cmd/beta/edge/token/create/create.go
@@ -0,0 +1,291 @@
+// 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-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"
+ "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"
+)
+
+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..c41e62044
--- /dev/null
+++ b/internal/cmd/beta/edge/token/create/create_test.go
@@ -0,0 +1,675 @@
+// 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"
+ 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-sdk-go/core/oapierror"
+ "github.com/stackitcloud/stackit-sdk-go/services/edge"
+)
+
+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/intake.go b/internal/cmd/beta/intake/intake.go
new file mode 100644
index 000000000..bf298f946
--- /dev/null
+++ b/internal/cmd/beta/intake/intake.go
@@ -0,0 +1,26 @@
+package intake
+
+import (
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/intake/runner"
+ "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))
+}
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..72cff3b29
--- /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 {
+ s := spinner.New(p.Printer)
+ s.Start("Creating STACKIT Intake Runner")
+ _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, resp.GetId()).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for STACKIT Intake Runner creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..c4f16995b
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/create/create_test.go
@@ -0,0 +1,294 @@
+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-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/intake"
+)
+
+// 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..92d5b1acf
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/delete/delete.go
@@ -0,0 +1,114 @@
+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"
+ 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"
+ "github.com/stackitcloud/stackit-sdk-go/services/intake"
+ "github.com/stackitcloud/stackit-sdk-go/services/intake/wait"
+)
+
+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 {
+ s := spinner.New(p.Printer)
+ s.Start("Deleting STACKIT Intake Runner")
+ _, err = wait.DeleteIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.RunnerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for STACKIT Intake Runner deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..b99edac92
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/delete/delete_test.go
@@ -0,0 +1,155 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/intake"
+)
+
+// 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..1cb034e04
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/describe/describe_test.go
@@ -0,0 +1,193 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/intake"
+)
+
+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..c9bdc9acd
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/list/list.go
@@ -0,0 +1,153 @@
+package list
+
+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/intake/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-sdk-go/services/intake"
+)
+
+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..bbce39c3e
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/list/list_test.go
@@ -0,0 +1,198 @@
+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-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/intake"
+)
+
+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..f923d96ff
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/runner.go
@@ -0,0 +1,31 @@
+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,
+ }
+ // 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))
+
+ return cmd
+}
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..a5f5bb55a
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/update/update.go
@@ -0,0 +1,177 @@
+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"
+ 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"
+ "github.com/stackitcloud/stackit-sdk-go/services/intake"
+ "github.com/stackitcloud/stackit-sdk-go/services/intake/wait"
+)
+
+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 {
+ s := spinner.New(p.Printer)
+ s.Start("Updating STACKIT Intake Runner")
+ _, err = wait.CreateOrUpdateIntakeRunnerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.RunnerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for STACKIT Intake Runner update: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..b702ede40
--- /dev/null
+++ b/internal/cmd/beta/intake/runner/update/update_test.go
@@ -0,0 +1,278 @@
+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-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/intake"
+)
+
+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/kms/key/create/create.go b/internal/cmd/beta/kms/key/create/create.go
new file mode 100644
index 000000000..de64af6a7
--- /dev/null
+++ b/internal/cmd/beta/kms/key/create/create.go
@@ -0,0 +1,217 @@
+package create
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms/wait"
+)
+
+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 beta 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 beta 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 beta 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 beta 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 beta 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 beta 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Creating key")
+ _, err = wait.CreateOrUpdateKeyWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, *resp.Id).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for KMS key creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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")
+ }
+
+ switch model.OutputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS key: %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 KMS key: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/key/create/create_test.go b/internal/cmd/beta/kms/key/create/create_test.go
new file mode 100644
index 000000000..76c04dc6b
--- /dev/null
+++ b/internal/cmd/beta/kms/key/create/create_test.go
@@ -0,0 +1,328 @@
+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-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/kms"
+)
+
+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/beta/kms/key/delete/delete.go b/internal/cmd/beta/kms/key/delete/delete.go
new file mode 100644
index 000000000..d7a6e02f6
--- /dev/null
+++ b/internal/cmd/beta/kms/key/delete/delete.go
@@ -0,0 +1,148 @@
+package delete
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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-cli/internal/pkg/services/kms/client"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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")
+ }
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal output to JSON: %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 output to YAML: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/key/delete/delete_test.go b/internal/cmd/beta/kms/key/delete/delete_test.go
new file mode 100644
index 000000000..78a2fee98
--- /dev/null
+++ b/internal/cmd/beta/kms/key/delete/delete_test.go
@@ -0,0 +1,293 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/key/describe/describe.go b/internal/cmd/beta/kms/key/describe/describe.go
new file mode 100644
index 000000000..4f036c374
--- /dev/null
+++ b/internal/cmd/beta/kms/key/describe/describe.go
@@ -0,0 +1,132 @@
+package describe
+
+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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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/beta/kms/key/describe/describe_test.go b/internal/cmd/beta/kms/key/describe/describe_test.go
new file mode 100644
index 000000000..6a34e5c74
--- /dev/null
+++ b/internal/cmd/beta/kms/key/describe/describe_test.go
@@ -0,0 +1,223 @@
+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-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/kms"
+)
+
+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/beta/kms/key/importKey/importKey.go b/internal/cmd/beta/kms/key/importKey/importKey.go
new file mode 100644
index 000000000..38010860e
--- /dev/null
+++ b/internal/cmd/beta/kms/key/importKey/importKey.go
@@ -0,0 +1,180 @@
+package importKey
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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-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-sdk-go/services/kms"
+)
+
+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 beta 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 beta 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")
+ }
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS key: %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 KMS key: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/key/importKey/importKey_test.go b/internal/cmd/beta/kms/key/importKey/importKey_test.go
new file mode 100644
index 000000000..378f34ea0
--- /dev/null
+++ b/internal/cmd/beta/kms/key/importKey/importKey_test.go
@@ -0,0 +1,363 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/key/key.go b/internal/cmd/beta/kms/key/key.go
new file mode 100644
index 000000000..d1ae57511
--- /dev/null
+++ b/internal/cmd/beta/kms/key/key.go
@@ -0,0 +1,38 @@
+package key
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/key/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/key/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/key/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/key/importKey"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/key/list"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/key/restore"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/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/beta/kms/key/list/list.go b/internal/cmd/beta/kms/key/list/list.go
new file mode 100644
index 000000000..576463689
--- /dev/null
+++ b/internal/cmd/beta/kms/key/list/list.go
@@ -0,0 +1,149 @@
+package list
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta kms key list --keyring-id "my-keyring-id"`),
+ examples.NewExample(
+ `List all KMS keys in JSON format`,
+ `$ stackit beta 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
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(keys, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS Keys list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(keys, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
+ if err != nil {
+ return fmt.Errorf("marshal KMS Keys list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/key/list/list_test.go b/internal/cmd/beta/kms/key/list/list_test.go
new file mode 100644
index 000000000..74491ae07
--- /dev/null
+++ b/internal/cmd/beta/kms/key/list/list_test.go
@@ -0,0 +1,259 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/key/restore/restore.go b/internal/cmd/beta/kms/key/restore/restore.go
new file mode 100644
index 000000000..c4fc71173
--- /dev/null
+++ b/internal/cmd/beta/kms/key/restore/restore.go
@@ -0,0 +1,147 @@
+package restore
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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-cli/internal/pkg/services/kms/client"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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")
+ }
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal output to JSON: %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 output to YAML: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ p.Outputf("Successfully restored KMS key %q\n", utils.PtrString(resp.DisplayName))
+ }
+ return nil
+}
diff --git a/internal/cmd/beta/kms/key/restore/restore_test.go b/internal/cmd/beta/kms/key/restore/restore_test.go
new file mode 100644
index 000000000..9c75b8ec0
--- /dev/null
+++ b/internal/cmd/beta/kms/key/restore/restore_test.go
@@ -0,0 +1,293 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/key/rotate/rotate.go b/internal/cmd/beta/kms/key/rotate/rotate.go
new file mode 100644
index 000000000..972234e7e
--- /dev/null
+++ b/internal/cmd/beta/kms/key/rotate/rotate.go
@@ -0,0 +1,143 @@
+package rotate
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/services/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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")
+ }
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS key version: %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 KMS key version: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ p.Outputf("Rotated key %s\n", utils.PtrString(resp.KeyId))
+ }
+
+ return nil
+}
diff --git a/internal/cmd/beta/kms/key/rotate/rotate_test.go b/internal/cmd/beta/kms/key/rotate/rotate_test.go
new file mode 100644
index 000000000..18965764d
--- /dev/null
+++ b/internal/cmd/beta/kms/key/rotate/rotate_test.go
@@ -0,0 +1,293 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/keyring/create/create.go b/internal/cmd/beta/kms/keyring/create/create.go
new file mode 100644
index 000000000..0e773b364
--- /dev/null
+++ b/internal/cmd/beta/kms/keyring/create/create.go
@@ -0,0 +1,181 @@
+package create
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms/wait"
+)
+
+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 beta kms keyring create --name my-keyring"),
+ examples.NewExample(
+ `Create a KMS key ring with a description`,
+ "$ stackit beta kms keyring create --name my-keyring --description my-description"),
+ examples.NewExample(
+ `Create a KMS key ring and print the result as YAML`,
+ "$ stackit beta 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Creating key ring")
+ _, err = wait.CreateKeyRingWaitHandler(ctx, apiClient, model.ProjectId, model.Region, keyRingId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for KMS key ring creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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")
+ }
+
+ switch model.OutputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS key ring: %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 KMS key ring: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/keyring/create/create_test.go b/internal/cmd/beta/kms/keyring/create/create_test.go
new file mode 100644
index 000000000..c4b307859
--- /dev/null
+++ b/internal/cmd/beta/kms/keyring/create/create_test.go
@@ -0,0 +1,250 @@
+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-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/kms"
+)
+
+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/beta/kms/keyring/delete/delete.go b/internal/cmd/beta/kms/keyring/delete/delete.go
new file mode 100644
index 000000000..6e028c75b
--- /dev/null
+++ b/internal/cmd/beta/kms/keyring/delete/delete.go
@@ -0,0 +1,104 @@
+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-cli/internal/pkg/services/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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/ske/credentials/describe/describe_test.go b/internal/cmd/beta/kms/keyring/delete/delete_test.go
similarity index 72%
rename from internal/cmd/ske/credentials/describe/describe_test.go
rename to internal/cmd/beta/kms/keyring/delete/delete_test.go
index 809b2eb16..4881e63e0 100644
--- a/internal/cmd/ske/credentials/describe/describe_test.go
+++ b/internal/cmd/beta/kms/keyring/delete/delete_test.go
@@ -1,30 +1,36 @@
-package describe
+package delete
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/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/ske"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu01"
+)
type testCtxKey struct{}
-var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &ske.APIClient{}
-var testProjectId = uuid.NewString()
-var testClusterName = "cluster"
+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{
- testClusterName,
+ testKeyRingId,
}
for _, mod := range mods {
mod(argValues)
@@ -32,9 +38,11 @@ func fixtureArgValues(mods ...func(argValues []string)) []string {
return argValues
}
+// Flags
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)
@@ -42,13 +50,15 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st
return flagValues
}
+// Input Model
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
- ClusterName: testClusterName,
+ KeyRingId: testKeyRingId,
}
for _, mod := range mods {
mod(model)
@@ -56,8 +66,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *ske.ApiGetCredentialsRequest)) ske.ApiGetCredentialsRequest {
- request := testClient.GetCredentials(testCtx, testProjectId, testClusterName)
+// Request
+func fixtureRequest(mods ...func(request *kms.ApiDeleteKeyRingRequest)) kms.ApiDeleteKeyRingRequest {
+ request := testClient.DeleteKeyRing(testCtx, testProjectId, testRegion, testKeyRingId)
for _, mod := range mods {
mod(&request)
}
@@ -80,28 +91,24 @@ func TestParseInput(t *testing.T) {
expectedModel: fixtureInputModel(),
},
{
- description: "no values",
- argValues: []string{},
- flagValues: map[string]string{},
- isValid: false,
- },
- {
- description: "no arg values",
+ description: "no args (keyRingId)",
argValues: []string{},
flagValues: fixtureFlagValues(),
isValid: false,
},
{
- description: "no flag values",
- argValues: fixtureArgValues(),
- flagValues: map[string]string{},
- 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, 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,
},
@@ -126,7 +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)
+ cmd := NewCmd(&types.CmdParams{Printer: p})
err := globalflags.Configure(cmd.Flags())
if err != nil {
t.Fatalf("configure global flags: %v", err)
@@ -169,7 +176,7 @@ func TestParseInput(t *testing.T) {
if !tt.isValid {
t.Fatalf("did not fail on invalid input")
}
- diff := cmp.Diff(model, tt.expectedModel)
+ diff := cmp.Diff(tt.expectedModel, model)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
}
@@ -181,10 +188,10 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest ske.ApiGetCredentialsRequest
+ expectedRequest kms.ApiDeleteKeyRingRequest
}{
{
- description: "base",
+ description: "base case",
model: fixtureInputModel(),
expectedRequest: fixtureRequest(),
},
@@ -194,7 +201,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),
)
diff --git a/internal/cmd/beta/kms/keyring/describe/describe.go b/internal/cmd/beta/kms/keyring/describe/describe.go
new file mode 100644
index 000000000..ed90cee8d
--- /dev/null
+++ b/internal/cmd/beta/kms/keyring/describe/describe.go
@@ -0,0 +1,107 @@
+package describe
+
+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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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/beta/kms/keyring/describe/describe_test.go b/internal/cmd/beta/kms/keyring/describe/describe_test.go
new file mode 100644
index 000000000..8c0a309f5
--- /dev/null
+++ b/internal/cmd/beta/kms/keyring/describe/describe_test.go
@@ -0,0 +1,184 @@
+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-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/kms"
+)
+
+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/beta/kms/keyring/keyring.go b/internal/cmd/beta/kms/keyring/keyring.go
new file mode 100644
index 000000000..8683a6907
--- /dev/null
+++ b/internal/cmd/beta/kms/keyring/keyring.go
@@ -0,0 +1,32 @@
+package keyring
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/keyring/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/keyring/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/keyring/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/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/beta/kms/keyring/list/list.go b/internal/cmd/beta/kms/keyring/list/list.go
new file mode 100644
index 000000000..240992c43
--- /dev/null
+++ b/internal/cmd/beta/kms/keyring/list/list.go
@@ -0,0 +1,134 @@
+package list
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta kms keyring list"),
+ examples.NewExample(
+ `List all KMS key rings in JSON format`,
+ "$ stackit beta 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
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(keyRings, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS key rings list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(keyRings, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
+ if err != nil {
+ return fmt.Errorf("marshal KMS key rings list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/keyring/list/list_test.go b/internal/cmd/beta/kms/keyring/list/list_test.go
new file mode 100644
index 000000000..d4e74c414
--- /dev/null
+++ b/internal/cmd/beta/kms/keyring/list/list_test.go
@@ -0,0 +1,230 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/kms.go b/internal/cmd/beta/kms/kms.go
new file mode 100644
index 000000000..1adfc3004
--- /dev/null
+++ b/internal/cmd/beta/kms/kms.go
@@ -0,0 +1,32 @@
+package kms
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/key"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/keyring"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/version"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/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/beta/kms/version/destroy/destroy.go b/internal/cmd/beta/kms/version/destroy/destroy.go
new file mode 100644
index 000000000..b33d5d5b6
--- /dev/null
+++ b/internal/cmd/beta/kms/version/destroy/destroy.go
@@ -0,0 +1,148 @@
+package destroy
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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")
+ }
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS key: %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 KMS key: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/version/destroy/destroy_test.go b/internal/cmd/beta/kms/version/destroy/destroy_test.go
new file mode 100644
index 000000000..2dde6cd9b
--- /dev/null
+++ b/internal/cmd/beta/kms/version/destroy/destroy_test.go
@@ -0,0 +1,320 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/version/disable/disable.go b/internal/cmd/beta/kms/version/disable/disable.go
new file mode 100644
index 000000000..9260c8e6a
--- /dev/null
+++ b/internal/cmd/beta/kms/version/disable/disable.go
@@ -0,0 +1,161 @@
+package disable
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms/wait"
+)
+
+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 beta 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Disabling key version")
+ _, err = wait.DisableKeyVersionWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for key version to be disabled: %w", err)
+ }
+ s.Stop()
+ }
+
+ // 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.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, resp *kms.Version) error {
+ if resp == nil {
+ return fmt.Errorf("response is nil")
+ }
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS key: %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 KMS key: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ p.Outputf("Disabled version %d of the key %q\n", utils.PtrValue(resp.Number), utils.PtrValue(resp.KeyId))
+ }
+
+ return nil
+}
diff --git a/internal/cmd/beta/kms/version/disable/disable_test.go b/internal/cmd/beta/kms/version/disable/disable_test.go
new file mode 100644
index 000000000..8108ea4b9
--- /dev/null
+++ b/internal/cmd/beta/kms/version/disable/disable_test.go
@@ -0,0 +1,321 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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
+ 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/beta/kms/version/enable/enable.go b/internal/cmd/beta/kms/version/enable/enable.go
new file mode 100644
index 000000000..06d8a85ec
--- /dev/null
+++ b/internal/cmd/beta/kms/version/enable/enable.go
@@ -0,0 +1,161 @@
+package enable
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms/wait"
+)
+
+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 beta 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Enabling key version")
+ _, err = wait.EnableKeyVersionWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.KeyRingId, model.KeyId, model.VersionNumber).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for key version to be enabled: %w", err)
+ }
+ s.Stop()
+ }
+
+ // 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.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, resp *kms.Version) error {
+ if resp == nil {
+ return fmt.Errorf("response is nil")
+ }
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS key: %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 KMS key: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ p.Outputf("Enabled version %d of the key %q\n", utils.PtrValue(resp.Number), utils.PtrValue(resp.KeyId))
+ }
+
+ return nil
+}
diff --git a/internal/cmd/beta/kms/version/enable/enable_test.go b/internal/cmd/beta/kms/version/enable/enable_test.go
new file mode 100644
index 000000000..0cc35d43f
--- /dev/null
+++ b/internal/cmd/beta/kms/version/enable/enable_test.go
@@ -0,0 +1,321 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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
+ 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.resp)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/kms/version/list/list.go b/internal/cmd/beta/kms/version/list/list.go
new file mode 100644
index 000000000..f9f606ac2
--- /dev/null
+++ b/internal/cmd/beta/kms/version/list/list.go
@@ -0,0 +1,151 @@
+package list
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta kms version list --key-id "my-key-id" --keyring-id "my-keyring-id"`),
+ examples.NewExample(
+ `List all key versions in JSON format`,
+ `$ stackit beta 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
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(versions, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal key versions list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(versions, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
+ if err != nil {
+ return fmt.Errorf("marshal key versions list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/version/list/list_test.go b/internal/cmd/beta/kms/version/list/list_test.go
new file mode 100644
index 000000000..e8e97d40c
--- /dev/null
+++ b/internal/cmd/beta/kms/version/list/list_test.go
@@ -0,0 +1,283 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/version/restore/restore.go b/internal/cmd/beta/kms/version/restore/restore.go
new file mode 100644
index 000000000..2f5f0882d
--- /dev/null
+++ b/internal/cmd/beta/kms/version/restore/restore.go
@@ -0,0 +1,146 @@
+package restore
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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")
+ }
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal output to JSON: %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 output to YAML: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/version/restore/restore_test.go b/internal/cmd/beta/kms/version/restore/restore_test.go
new file mode 100644
index 000000000..7454fc5a5
--- /dev/null
+++ b/internal/cmd/beta/kms/version/restore/restore_test.go
@@ -0,0 +1,321 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/version/version.go b/internal/cmd/beta/kms/version/version.go
new file mode 100644
index 000000000..60b642679
--- /dev/null
+++ b/internal/cmd/beta/kms/version/version.go
@@ -0,0 +1,34 @@
+package version
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/version/destroy"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/version/disable"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/version/enable"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/version/list"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/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/beta/kms/wrappingkey/create/create.go b/internal/cmd/beta/kms/wrappingkey/create/create.go
new file mode 100644
index 000000000..885c3b3e2
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/create/create.go
@@ -0,0 +1,203 @@
+package create
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms/wait"
+)
+
+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 beta 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 beta 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Creating wrapping key")
+ _, err = wait.CreateWrappingKeyWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *wrappingKey.KeyRingId, *wrappingKey.Id).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for KMS wrapping key creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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")
+ }
+
+ switch model.OutputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS wrapping key: %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 KMS wrapping key: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/wrappingkey/create/create_test.go b/internal/cmd/beta/kms/wrappingkey/create/create_test.go
new file mode 100644
index 000000000..2b7d356de
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/create/create_test.go
@@ -0,0 +1,319 @@
+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-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/kms"
+)
+
+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/beta/kms/wrappingkey/delete/delete.go b/internal/cmd/beta/kms/wrappingkey/delete/delete.go
new file mode 100644
index 000000000..75ce76a05
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/delete/delete.go
@@ -0,0 +1,117 @@
+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-cli/internal/pkg/services/kms/client"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta 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/beta/kms/wrappingkey/delete/delete_test.go b/internal/cmd/beta/kms/wrappingkey/delete/delete_test.go
new file mode 100644
index 000000000..c8d3a2ee2
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/delete/delete_test.go
@@ -0,0 +1,242 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/wrappingkey/describe/describe.go b/internal/cmd/beta/kms/wrappingkey/describe/describe.go
new file mode 100644
index 000000000..f8edb6921
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/describe/describe.go
@@ -0,0 +1,132 @@
+package describe
+
+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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta kms wrappingkey 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/beta/kms/wrappingkey/describe/describe_test.go b/internal/cmd/beta/kms/wrappingkey/describe/describe_test.go
new file mode 100644
index 000000000..579f27f3d
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/describe/describe_test.go
@@ -0,0 +1,216 @@
+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-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/kms"
+)
+
+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/beta/kms/wrappingkey/list/list.go b/internal/cmd/beta/kms/wrappingkey/list/list.go
new file mode 100644
index 000000000..dbb514812
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/list/list.go
@@ -0,0 +1,150 @@
+package list
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/goccy/go-yaml"
+ "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/kms/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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 beta kms wrapping-key list --keyring-id "my-keyring-id"`),
+ examples.NewExample(
+ `List all KMS wrapping keys in JSON format`,
+ `$ stackit beta 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
+
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(wrappingKeys, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal KMS wrapping keys list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(wrappingKeys, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
+ if err != nil {
+ return fmt.Errorf("marshal KMS wrapping keys list: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ 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/beta/kms/wrappingkey/list/list_test.go b/internal/cmd/beta/kms/wrappingkey/list/list_test.go
new file mode 100644
index 000000000..05c571ed3
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/list/list_test.go
@@ -0,0 +1,251 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/kms"
+)
+
+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/beta/kms/wrappingkey/wrappingkey.go b/internal/cmd/beta/kms/wrappingkey/wrappingkey.go
new file mode 100644
index 000000000..2aaa14640
--- /dev/null
+++ b/internal/cmd/beta/kms/wrappingkey/wrappingkey.go
@@ -0,0 +1,32 @@
+package wrappingkey
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/wrappingkey/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/wrappingkey/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms/wrappingkey/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/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/beta/logs/instance/create/create.go b/internal/cmd/beta/logs/instance/create/create.go
new file mode 100644
index 000000000..2dafa7c1e
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/create/create.go
@@ -0,0 +1,176 @@
+package create
+
+import (
+ "context"
+ "fmt"
+
+ "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/stackitcloud/stackit-sdk-go/services/logs"
+
+ "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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/logs/wait"
+)
+
+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 beta 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 beta 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 beta 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Creating instance")
+ _, err = wait.CreateLogsInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for logs instance creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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/beta/logs/instance/create/create_test.go b/internal/cmd/beta/logs/instance/create/create_test.go
new file mode 100644
index 000000000..a1835d54c
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/create/create_test.go
@@ -0,0 +1,256 @@
+package create
+
+import (
+ "context"
+ "strconv"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+ "github.com/stackitcloud/stackit-sdk-go/services/logs"
+
+ "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/beta/logs/instance/delete/delete.go b/internal/cmd/beta/logs/instance/delete/delete.go
new file mode 100644
index 000000000..3344a3a93
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/delete/delete.go
@@ -0,0 +1,129 @@
+package delete
+
+import (
+ "context"
+ "fmt"
+
+ "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/stackitcloud/stackit-sdk-go/services/logs"
+
+ "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-cli/internal/pkg/services/logs/client"
+ logsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/logs/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/logs/wait"
+)
+
+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 beta 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting instance")
+ _, err = wait.DeleteLogsInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.InstanceID).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for Logs instance deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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/beta/logs/instance/delete/delete_test.go b/internal/cmd/beta/logs/instance/delete/delete_test.go
new file mode 100644
index 000000000..7b89cccb0
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/delete/delete_test.go
@@ -0,0 +1,174 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/logs"
+)
+
+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/beta/logs/instance/describe/describe.go b/internal/cmd/beta/logs/instance/describe/describe.go
new file mode 100644
index 000000000..66d27d99a
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/describe/describe.go
@@ -0,0 +1,118 @@
+package describe
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+ "github.com/stackitcloud/stackit-sdk-go/services/logs"
+
+ "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 beta logs instance describe xxx`,
+ ),
+ examples.NewExample(
+ `Get details of a Logs instance with ID "xxx" in JSON format`,
+ "$ stackit beta 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/beta/logs/instance/describe/describe_test.go b/internal/cmd/beta/logs/instance/describe/describe_test.go
new file mode 100644
index 000000000..661ce4aa5
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/describe/describe_test.go
@@ -0,0 +1,191 @@
+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-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-sdk-go/services/logs"
+)
+
+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/beta/logs/instance/instance.go b/internal/cmd/beta/logs/instance/instance.go
new file mode 100644
index 000000000..ad099a9d1
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/instance.go
@@ -0,0 +1,34 @@
+package instance
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/logs/instance/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/logs/instance/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/logs/instance/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/logs/instance/list"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/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/beta/logs/instance/list/list.go b/internal/cmd/beta/logs/instance/list/list.go
new file mode 100644
index 000000000..226c3b5c3
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/list/list.go
@@ -0,0 +1,147 @@
+package list
+
+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/logs/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/logs"
+)
+
+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 beta logs instance list`,
+ ),
+ examples.NewExample(
+ `List the first 10 Logs instances`,
+ `$ stackit beta 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/beta/logs/instance/list/list_test.go b/internal/cmd/beta/logs/instance/list/list_test.go
new file mode 100644
index 000000000..987d0e8d8
--- /dev/null
+++ b/internal/cmd/beta/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/beta/logs/instance/update/update.go b/internal/cmd/beta/logs/instance/update/update.go
new file mode 100644
index 000000000..2e6fcea0b
--- /dev/null
+++ b/internal/cmd/beta/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 beta logs instance update xxx --display-name new-name"),
+ examples.NewExample(
+ `Update the retention time of the Logs instance with ID "xxx"`,
+ "$ stackit beta logs instance update xxx --retention-days 40"),
+ examples.NewExample(
+ `Update the ACL of the Logs instance with ID "xxx"`,
+ "$ stackit beta 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/beta/logs/instance/update/update_test.go b/internal/cmd/beta/logs/instance/update/update_test.go
new file mode 100644
index 000000000..b885d5677
--- /dev/null
+++ b/internal/cmd/beta/logs/instance/update/update_test.go
@@ -0,0 +1,306 @@
+package update
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+ "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/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/beta/logs/logs.go b/internal/cmd/beta/logs/logs.go
new file mode 100644
index 000000000..d7e0b803a
--- /dev/null
+++ b/internal/cmd/beta/logs/logs.go
@@ -0,0 +1,26 @@
+package logs
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/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))
+}
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..8732825aa
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/create/create.go
@@ -0,0 +1,149 @@
+package create
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "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/sfs/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..f94074b1a
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/create/create_test.go
@@ -0,0 +1,212 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..8ae903d23
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/delete/delete.go
@@ -0,0 +1,98 @@
+package delete
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..c38c47ae6
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/delete/delete_test.go
@@ -0,0 +1,174 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..a9bc7b9b3
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/describe/describe.go
@@ -0,0 +1,151 @@
+package describe
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..73e711879
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/describe/describe_test.go
@@ -0,0 +1,221 @@
+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-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-sdk-go/services/sfs"
+)
+
+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..7620dfc33
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/list/list.go
@@ -0,0 +1,147 @@
+package list
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..e2a73ace0
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/list/list_test.go
@@ -0,0 +1,176 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..a0ab0a6dc
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/update/update.go
@@ -0,0 +1,170 @@
+package update
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..0ca322850
--- /dev/null
+++ b/internal/cmd/beta/sfs/export-policy/update/update_test.go
@@ -0,0 +1,251 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..0ca22c392
--- /dev/null
+++ b/internal/cmd/beta/sfs/performance-class/list/list.go
@@ -0,0 +1,110 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..655159342
--- /dev/null
+++ b/internal/cmd/beta/sfs/performance-class/list/list_test.go
@@ -0,0 +1,170 @@
+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-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-sdk-go/services/sfs"
+)
+
+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..4b97f16bb
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/create/create.go
@@ -0,0 +1,182 @@
+package create
+
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Create resource pool")
+ _, err = wait.CreateResourcePoolWaitHandler(ctx, apiClient, model.ProjectId, model.Region, resourcePoolId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for resource pool creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, 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, 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
+ }
+ p.Outputf("Created resource pool for project %q. Resource pool ID: %s\n", 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..5dd65f643
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/create/create_test.go
@@ -0,0 +1,295 @@
+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-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-sdk-go/services/sfs"
+)
+
+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
+ 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.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..6883c3987
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/delete/delete.go
@@ -0,0 +1,118 @@
+package delete
+
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Delete resource pool")
+ _, err = wait.DeleteResourcePoolWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for resource pool deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, 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, resourcePoolName string, response map[string]interface{}) error {
+ return p.OutputResult(outputFormat, response, func() error {
+ p.Outputf("Deleted resource pool %q\n", 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..b41ed2626
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/delete/delete_test.go
@@ -0,0 +1,209 @@
+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-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-sdk-go/services/sfs"
+)
+
+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
+ 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.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..0567b950e
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/describe/describe.go
@@ -0,0 +1,152 @@
+package describe
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..8fddcd529
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/describe/describe_test.go
@@ -0,0 +1,211 @@
+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-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-sdk-go/services/sfs"
+)
+
+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..d62dbd7b6
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/list/list.go
@@ -0,0 +1,153 @@
+package list
+
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..4670a7856
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/list/list_test.go
@@ -0,0 +1,199 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..a276fb91e
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/update/update.go
@@ -0,0 +1,177 @@
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Update resource pool")
+ _, err = wait.UpdateResourcePoolWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for resource pool update: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, 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, 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
+ }
+ p.Outputf("Updated resource pool %s\n", 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..c94b79b92
--- /dev/null
+++ b/internal/cmd/beta/sfs/resource-pool/update/update_test.go
@@ -0,0 +1,361 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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
+ 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.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..c2b2246b8
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/create/create.go
@@ -0,0 +1,179 @@
+package create
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Creating share")
+ _, err = wait.CreateShareWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId, shareId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("waiting for share creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..f535093e3
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/create/create_test.go
@@ -0,0 +1,207 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..aed603d52
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/delete/delete.go
@@ -0,0 +1,132 @@
+package delete
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting share")
+ _, err = wait.DeleteShareWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("waiting for share deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..60040fc44
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/delete/delete_test.go
@@ -0,0 +1,186 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..f593f9122
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/describe/describe.go
@@ -0,0 +1,191 @@
+package describe
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..889117485
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/describe/describe_test.go
@@ -0,0 +1,233 @@
+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-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-sdk-go/services/sfs"
+)
+
+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..e8945cd04
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/list/list.go
@@ -0,0 +1,158 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..7cff82a0d
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/list/list_test.go
@@ -0,0 +1,207 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..7f368fa3d
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/update/update.go
@@ -0,0 +1,179 @@
+package update
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Updating share")
+ _, err = wait.UpdateShareWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ResourcePoolId, model.ShareId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("waiting for share update: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..99494e905
--- /dev/null
+++ b/internal/cmd/beta/sfs/share/update/update_test.go
@@ -0,0 +1,266 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..c531009de
--- /dev/null
+++ b/internal/cmd/beta/sfs/snapshot/create/create.go
@@ -0,0 +1,140 @@
+package create
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..9ac34a9a8
--- /dev/null
+++ b/internal/cmd/beta/sfs/snapshot/create/create_test.go
@@ -0,0 +1,218 @@
+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-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/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..15e0cec17
--- /dev/null
+++ b/internal/cmd/beta/sfs/snapshot/delete/delete.go
@@ -0,0 +1,112 @@
+package delete
+
+import (
+ "context"
+ "fmt"
+
+ "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/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-sdk-go/services/sfs"
+)
+
+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..3a3048b64
--- /dev/null
+++ b/internal/cmd/beta/sfs/snapshot/delete/delete_test.go
@@ -0,0 +1,188 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..4d45233fd
--- /dev/null
+++ b/internal/cmd/beta/sfs/snapshot/describe/describe.go
@@ -0,0 +1,129 @@
+package describe
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..f307c1f19
--- /dev/null
+++ b/internal/cmd/beta/sfs/snapshot/describe/describe_test.go
@@ -0,0 +1,233 @@
+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-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-sdk-go/services/sfs"
+)
+
+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..d2caf7b58
--- /dev/null
+++ b/internal/cmd/beta/sfs/snapshot/list/list.go
@@ -0,0 +1,148 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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..3c63ed05c
--- /dev/null
+++ b/internal/cmd/beta/sfs/snapshot/list/list_test.go
@@ -0,0 +1,173 @@
+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-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-sdk-go/services/sfs"
+)
+
+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 4cb15352f..9a2b8c2e0 100644
--- a/internal/cmd/beta/sqlserverflex/database/create/create.go
+++ b/internal/cmd/beta/sqlserverflex/database/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"
@@ -14,7 +14,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-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
"github.com/spf13/cobra"
@@ -34,7 +33,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",
@@ -50,28 +49,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
}
- 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 := spinner.New(params.Printer)
s.Start("Creating database")
resp, err := req.Execute()
if err != nil {
@@ -80,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
s.Stop()
- return outputResult(p, model, resp)
+ return outputResult(params.Printer, model.OutputFormat, model.DatabaseName, resp)
},
}
configureFlags(cmd)
@@ -109,50 +106,29 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverflex.APIClient) sqlserverflex.ApiCreateDatabaseRequest {
- req := apiClient.CreateDatabase(ctx, model.ProjectId, model.InstanceId)
+ req := apiClient.CreateDatabase(ctx, model.ProjectId, model.InstanceId, model.Region)
payload := sqlserverflex.CreateDatabasePayload{
Name: &model.DatabaseName,
- Options: utils.Ptr(map[string]string{
- "owner": model.Owner,
- }),
+ Options: &sqlserverflex.DatabaseDocumentationCreateDatabaseRequestOptions{
+ Owner: &model.Owner,
+ },
}
req = req.CreateDatabasePayload(payload)
return req
}
-func outputResult(p *print.Printer, model *inputModel, resp *sqlserverflex.CreateDatabaseResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal SQLServer Flex database: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, databaseName string, resp *sqlserverflex.CreateDatabaseResponse) error {
+ if resp == nil {
+ return fmt.Errorf("sqlserverflex response is empty")
+ }
+ return p.OutputResult(outputFormat, resp, func() error {
+ p.Outputf("Created database %q\n", databaseName)
return nil
- default:
- p.Outputf("Created database %q\n", model.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 fc98c388a..f33b7623c 100644
--- a/internal/cmd/beta/sqlserverflex/database/create/create_test.go
+++ b/internal/cmd/beta/sqlserverflex/database/create/create_test.go
@@ -4,18 +4,17 @@ 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-sdk-go/services/sqlserverflex"
+ "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/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -24,6 +23,7 @@ var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testDatabaseName = "my-database"
var testOwner = "owner"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -37,9 +37,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,
- ownerFlag: testOwner,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
+ ownerFlag: testOwner,
}
for _, mod := range mods {
mod(flagValues)
@@ -52,6 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
DatabaseName: testDatabaseName,
InstanceId: testInstanceId,
@@ -64,12 +66,12 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiCreateDatabaseRequest)) sqlserverflex.ApiCreateDatabaseRequest {
- request := testClient.CreateDatabase(testCtx, testProjectId, testInstanceId)
+ request := testClient.CreateDatabase(testCtx, testProjectId, testInstanceId, testRegion)
payload := sqlserverflex.CreateDatabasePayload{
Name: &testDatabaseName,
- Options: utils.Ptr(map[string]string{
- "owner": testOwner,
- }),
+ Options: &sqlserverflex.DatabaseDocumentationCreateDatabaseRequestOptions{
+ Owner: &testOwner,
+ },
}
request = request.CreateDatabasePayload(payload)
for _, mod := range mods {
@@ -115,7 +117,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,
},
@@ -123,7 +125,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,
},
@@ -131,7 +133,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,
},
@@ -177,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)
})
}
}
@@ -256,3 +211,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ databaseName string
+ resp *sqlserverflex.CreateDatabaseResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only sql response as argument",
+ args: args{
+ resp: &sqlserverflex.CreateDatabaseResponse{},
+ },
+ 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.databaseName, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/sqlserverflex/database/database.go b/internal/cmd/beta/sqlserverflex/database/database.go
index 382b646c1..75113d255 100644
--- a/internal/cmd/beta/sqlserverflex/database/database.go
+++ b/internal/cmd/beta/sqlserverflex/database/database.go
@@ -3,14 +3,16 @@ package database
import (
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/database/create"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/database/delete"
+ "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",
@@ -18,11 +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))
+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 6f6426bea..3408cc85d 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,28 +47,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
}
- 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 := spinner.New(params.Printer)
s.Start("Deleting database")
err = req.Execute()
if err != nil {
@@ -75,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
s.Stop()
- p.Info("Deleted database %q\n", model.DatabaseName)
+ params.Printer.Info("Deleted database %q\n", model.DatabaseName)
return nil
},
}
@@ -103,19 +103,11 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverflex.APIClient) sqlserverflex.ApiDeleteDatabaseRequest {
- req := apiClient.DeleteDatabase(ctx, model.ProjectId, model.InstanceId, model.DatabaseName)
+ req := apiClient.DeleteDatabase(ctx, model.ProjectId, model.InstanceId, model.DatabaseName, model.Region)
return req
}
diff --git a/internal/cmd/beta/sqlserverflex/database/delete/delete_test.go b/internal/cmd/beta/sqlserverflex/database/delete/delete_test.go
index ede474f38..ab137dcd0 100644
--- a/internal/cmd/beta/sqlserverflex/database/delete/delete_test.go
+++ b/internal/cmd/beta/sqlserverflex/database/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/stackitcloud/stackit-sdk-go/services/sqlserverflex"
"github.com/google/go-cmp/cmp"
@@ -13,8 +13,6 @@ import (
"github.com/google/uuid"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,6 +20,7 @@ var testClient = &sqlserverflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testDatabaseName = "my-database"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -35,8 +34,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 +48,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
DatabaseName: testDatabaseName,
@@ -60,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiDeleteDatabaseRequest)) sqlserverflex.ApiDeleteDatabaseRequest {
- request := testClient.DeleteDatabase(testCtx, testProjectId, testInstanceId, testDatabaseName)
+ request := testClient.DeleteDatabase(testCtx, testProjectId, testInstanceId, testDatabaseName, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -104,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,
},
@@ -112,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,
},
@@ -120,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,
},
@@ -158,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 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
new file mode 100644
index 000000000..7ad000553
--- /dev/null
+++ b/internal/cmd/beta/sqlserverflex/database/describe/describe.go
@@ -0,0 +1,136 @@
+package describe
+
+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/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 (
+ databaseNameArg = "DATABASE_NAME"
+
+ instanceIdFlag = "instance-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ DatabaseName string
+ InstanceId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", databaseNameArg),
+ Short: "Shows details of an SQLServer Flex database",
+ Long: "Shows details of an SQLServer Flex database.",
+ Args: args.SingleArg(databaseNameArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of an SQLServer Flex database with name "my-database" of instance with ID "xxx"`,
+ "$ stackit beta sqlserverflex database describe my-database --instance-id xxx"),
+ examples.NewExample(
+ `Get details of an SQLServer Flex database with name "my-database" of instance with ID "xxx" in JSON format`,
+ "$ stackit beta sqlserverflex database describe my-database --instance-id 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 SQLServer Flex database: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "SQLServer Flex instance ID")
+
+ err := flags.MarkFlagsRequired(cmd, instanceIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ databaseName := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ DatabaseName: databaseName,
+ InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverflex.APIClient) sqlserverflex.ApiGetDatabaseRequest {
+ req := apiClient.GetDatabase(ctx, model.ProjectId, model.InstanceId, model.DatabaseName, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, resp *sqlserverflex.GetDatabaseResponse) error {
+ if resp == nil || resp.Database == nil {
+ return fmt.Errorf("database response is empty")
+ }
+
+ return p.OutputResult(outputFormat, resp, func() error {
+ database := resp.Database
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(database.Id))
+ table.AddSeparator()
+ table.AddRow("NAME", utils.PtrString(database.Name))
+ table.AddSeparator()
+ if database.Options != nil {
+ if database.Options.CompatibilityLevel != nil {
+ table.AddRow("COMPATIBILITY LEVEL", *database.Options.CompatibilityLevel)
+ table.AddSeparator()
+ }
+ if database.Options.Owner != nil {
+ table.AddRow("OWNER", *database.Options.Owner)
+ table.AddSeparator()
+ }
+ if database.Options.CollationName != nil {
+ table.AddRow("COLLATION", *database.Options.CollationName)
+ }
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/beta/sqlserverflex/database/describe/describe_test.go b/internal/cmd/beta/sqlserverflex/database/describe/describe_test.go
new file mode 100644
index 000000000..2a4bc2b01
--- /dev/null
+++ b/internal/cmd/beta/sqlserverflex/database/describe/describe_test.go
@@ -0,0 +1,236 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &sqlserverflex.APIClient{}
+var testProjectId = uuid.NewString()
+var testInstanceId = uuid.NewString()
+var testDatabaseName = "my-database"
+var testRegion = "eu01"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testDatabaseName,
+ }
+ 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{
+ ProjectId: testProjectId,
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ DatabaseName: testDatabaseName,
+ InstanceId: testInstanceId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *sqlserverflex.ApiGetDatabaseRequest)) sqlserverflex.ApiGetDatabaseRequest {
+ request := testClient.GetDatabase(testCtx, testProjectId, testInstanceId, testDatabaseName, 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",
+ 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: "database name invalid",
+ argValues: []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.argValues, tt.flagValues, tt.isValid)
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest sqlserverflex.ApiGetDatabaseRequest
+ }{
+ {
+ 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 *sqlserverflex.GetDatabaseResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty response",
+ args: args{
+ resp: &sqlserverflex.GetDatabaseResponse{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "only database as argument",
+ args: args{
+ resp: &sqlserverflex.GetDatabaseResponse{Database: &sqlserverflex.SingleDatabase{}},
+ },
+ 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/sqlserverflex/database/list/list.go b/internal/cmd/beta/sqlserverflex/database/list/list.go
new file mode 100644
index 000000000..439b069df
--- /dev/null
+++ b/internal/cmd/beta/sqlserverflex/database/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-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/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 (
+ instanceIdFlag = "instance-id"
+ limitFlag = "limit"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ InstanceId string
+ Limit *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all SQLServer Flex databases",
+ Long: "Lists all SQLServer Flex databases.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all SQLServer Flex databases of instance with ID "xxx"`,
+ "$ stackit beta sqlserverflex database list --instance-id xxx"),
+ examples.NewExample(
+ `List all SQLServer Flex databases of instance with ID "xxx" in JSON format`,
+ "$ stackit beta sqlserverflex database list --instance-id xxx --output-format json"),
+ examples.NewExample(
+ `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, 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 SQLServer Flex databases: %w", err)
+ }
+ 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
+ }
+
+ // Truncate output
+ if model.Limit != nil && len(databases) > int(*model.Limit) {
+ databases = databases[:*model.Limit]
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.InstanceId, projectLabel, databases)
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "SQLServer Flex instance ID")
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+
+ 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,
+ InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag),
+ Limit: flags.FlagToInt64Pointer(p, cmd, limitFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverflex.APIClient) sqlserverflex.ApiListDatabasesRequest {
+ req := apiClient.ListDatabases(ctx, model.ProjectId, model.InstanceId, model.Region)
+ return req
+}
+
+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
+ }
+
+ table := tables.NewTable()
+ table.SetHeader("ID", "NAME")
+ for i := range databases {
+ database := databases[i]
+ table.AddRow(utils.PtrString(database.Id), utils.PtrString(database.Name))
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/beta/sqlserverflex/database/list/list_test.go b/internal/cmd/beta/sqlserverflex/database/list/list_test.go
new file mode 100644
index 000000000..236f64f78
--- /dev/null
+++ b/internal/cmd/beta/sqlserverflex/database/list/list_test.go
@@ -0,0 +1,211 @@
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &sqlserverflex.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,
+ instanceIdFlag: testInstanceId,
+ limitFlag: "10",
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ InstanceId: testInstanceId,
+ Limit: utils.Ptr(int64(10)),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *sqlserverflex.ApiListDatabasesRequest)) sqlserverflex.ApiListDatabasesRequest {
+ request := testClient.ListDatabases(testCtx, testProjectId, testInstanceId, 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: "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 sqlserverflex.ApiListDatabasesRequest
+ }{
+ {
+ 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
+ instanceId string
+ projectLabel string
+ databases []sqlserverflex.Database
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty database in databases slice",
+ args: args{
+ databases: []sqlserverflex.Database{{}},
+ },
+ 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.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 62cee12dc..4cc0469e5 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"
@@ -24,6 +24,17 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex/wait"
)
+// enforce implementation of interfaces
+var (
+ _ sqlServerFlexClient = &sqlserverflex.APIClient{}
+)
+
+type sqlServerFlexClient interface {
+ CreateInstance(ctx context.Context, projectId string, region string) sqlserverflex.ApiCreateInstanceRequest
+ ListFlavorsExecute(ctx context.Context, projectId string, region string) (*sqlserverflex.ListFlavorsResponse, error)
+ ListStoragesExecute(ctx context.Context, projectId, flavorId string, region string) (*sqlserverflex.ListStoragesResponse, error)
+}
+
const (
instanceNameFlag = "name"
aclFlag = "acl"
@@ -54,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",
@@ -65,38 +76,37 @@ func NewCmd(p *print.Printer) *cobra.Command {
`Create a SQLServer Flex instance with name "my-instance" and specify flavor by CPU and RAM. Other parameters are set to default values`,
`$ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4`),
examples.NewExample(
- `Create a SQLServer Flex instance with name "my-instance" and specify flavor by ID. Other parameters are set to default values`,
+ `Create a SQLServer Flex instance with name "my-instance" and specify flavor by ID. Other parameters are set to default values.
+ The flavor ID can be retrieved by running "$ stackit beta sqlserverflex options --flavors"`,
`$ 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, 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
@@ -112,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 := spinner.New(params.Printer)
s.Start("Creating instance")
- _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
+ _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx)
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)
@@ -145,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{}
@@ -183,31 +193,17 @@ 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
}
-type sqlServerFlexClient interface {
- CreateInstance(ctx context.Context, projectId string) sqlserverflex.ApiCreateInstanceRequest
- ListFlavorsExecute(ctx context.Context, projectId string) (*sqlserverflex.ListFlavorsResponse, error)
- ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*sqlserverflex.ListStoragesResponse, error)
-}
-
func buildRequest(ctx context.Context, model *inputModel, apiClient sqlServerFlexClient) (sqlserverflex.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 SQLServer Flex flavors: %w", err)
}
@@ -229,7 +225,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient sqlServerFle
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 SQLServer Flex storages: %w", err)
}
@@ -262,29 +258,15 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient sqlServerFle
}
func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *sqlserverflex.CreateInstanceResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal SQLServerFlex instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+ if resp == nil {
+ return fmt.Errorf("sqlserverflex response is empty")
+ }
+ 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, *resp.Id)
+ 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 10f6fc84e..8168cecc6 100644
--- a/internal/cmd/beta/sqlserverflex/instance/create/create_test.go
+++ b/internal/cmd/beta/sqlserverflex/instance/create/create_test.go
@@ -5,22 +5,28 @@ import (
"fmt"
"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-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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &sqlserverflex.APIClient{}
+var testRegion = "eu01"
+
+// enforce implementation of interfaces
+var (
+ _ sqlServerFlexClient = &sqlServerFlexClientMocked{}
+)
type sqlServerFlexClientMocked struct {
listFlavorsFails bool
@@ -29,18 +35,18 @@ type sqlServerFlexClientMocked struct {
listStoragesResp *sqlserverflex.ListStoragesResponse
}
-func (c *sqlServerFlexClientMocked) CreateInstance(ctx context.Context, projectId string) sqlserverflex.ApiCreateInstanceRequest {
- return testClient.CreateInstance(ctx, projectId)
+func (c *sqlServerFlexClientMocked) CreateInstance(ctx context.Context, projectId, region string) sqlserverflex.ApiCreateInstanceRequest {
+ return testClient.CreateInstance(ctx, projectId, region)
}
-func (c *sqlServerFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*sqlserverflex.ListStoragesResponse, error) {
+func (c *sqlServerFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, _ string) (*sqlserverflex.ListStoragesResponse, error) {
if c.listFlavorsFails {
return nil, fmt.Errorf("list storages failed")
}
return c.listStoragesResp, nil
}
-func (c *sqlServerFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*sqlserverflex.ListFlavorsResponse, error) {
+func (c *sqlServerFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*sqlserverflex.ListFlavorsResponse, error) {
if c.listFlavorsFails {
return nil, fmt.Errorf("list flavors failed")
}
@@ -52,16 +58,17 @@ 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: "storage-class", // Non-default
- storageSizeFlag: "10",
- versionFlag: "6.0",
- editionFlag: "developer",
- retentionDaysFlag: "32",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceNameFlag: "example-name",
+ aclFlag: "0.0.0.0/0",
+ backupScheduleFlag: "0 0/6 * * *",
+ flavorIdFlag: testFlavorId,
+ storageClassFlag: "storage-class", // Non-default
+ storageSizeFlag: "10",
+ versionFlag: "6.0",
+ editionFlag: "developer",
+ retentionDaysFlag: "32",
}
for _, mod := range mods {
mod(flagValues)
@@ -73,6 +80,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"),
@@ -92,7 +100,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiCreateInstanceRequest)) sqlserverflex.ApiCreateInstanceRequest {
- request := testClient.CreateInstance(testCtx, testProjectId)
+ request := testClient.CreateInstance(testCtx, testProjectId, testRegion)
request = request.CreateInstancePayload(fixturePayload())
for _, mod := range mods {
mod(&request)
@@ -125,6 +133,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
@@ -158,21 +167,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,
},
@@ -245,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)
})
}
}
@@ -492,3 +454,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ projectLabel string
+ resp *sqlserverflex.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "sql instance as argument",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &sqlserverflex.CreateInstanceResponse{},
+ },
+ 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/sqlserverflex/instance/delete/delete.go b/internal/cmd/beta/sqlserverflex/instance/delete/delete.go
index a735efa15..74840b4db 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)
+ 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,9 +75,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
- _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
+ _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for SQLServer Flex instance deletion: %w", err)
}
@@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 *sqlserverflex.APIClient) sqlserverflex.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/beta/sqlserverflex/instance/delete/delete_test.go b/internal/cmd/beta/sqlserverflex/instance/delete/delete_test.go
index f96593f63..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"
@@ -13,14 +13,13 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &sqlserverflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -34,7 +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,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -46,6 +46,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -57,7 +58,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiDeleteInstanceRequest)) sqlserverflex.ApiDeleteInstanceRequest {
- request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId)
+ request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -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)
})
}
}
diff --git a/internal/cmd/beta/sqlserverflex/instance/describe/describe.go b/internal/cmd/beta/sqlserverflex/instance/describe/describe.go
index a9e868292..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,72 +81,57 @@ 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 *sqlserverflex.APIClient) sqlserverflex.ApiGetInstanceRequest {
- req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId)
+ req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId, model.Region)
return req
}
func outputResult(p *print.Printer, outputFormat string, instance *sqlserverflex.Instance) error {
- 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))
+ if instance == nil {
+ return fmt.Errorf("instance response is empty")
+ }
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(instance, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal SQLServer Flex instance: %w", err)
+ return p.OutputResult(outputFormat, instance, func() error {
+ var acls string
+ if instance.Acl != nil && instance.Acl.HasItems() {
+ aclsArray := *instance.Acl.Items
+ acls = strings.Join(aclsArray, ",")
}
- p.Outputln(string(details))
-
- return nil
- default:
- aclsArray := *instance.Acl.Items
- acls := strings.Join(aclsArray, ",")
table := tables.NewTable()
- table.AddRow("ID", *instance.Id)
- table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
+ table.AddRow("ID", utils.PtrString(instance.Id))
table.AddSeparator()
- table.AddRow("STATUS", *instance.Status)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("STORAGE SIZE (GB)", *instance.Storage.Size)
+ table.AddRow("STATUS", utils.PtrString(instance.Status))
table.AddSeparator()
- table.AddRow("VERSION", *instance.Version)
+ if instance.Storage != nil {
+ table.AddRow("STORAGE SIZE (GB)", utils.PtrString(instance.Storage.Size))
+ table.AddSeparator()
+ }
+ table.AddRow("VERSION", utils.PtrString(instance.Version))
table.AddSeparator()
- table.AddRow("BACKUP SCHEDULE (UTC)", *instance.BackupSchedule)
+ table.AddRow("BACKUP SCHEDULE (UTC)", utils.PtrString(instance.BackupSchedule))
table.AddSeparator()
table.AddRow("ACL", acls)
table.AddSeparator()
- table.AddRow("FLAVOR DESCRIPTION", *instance.Flavor.Description)
- table.AddSeparator()
- table.AddRow("CPU", *instance.Flavor.Cpu)
- table.AddSeparator()
- table.AddRow("RAM (GB)", *instance.Flavor.Memory)
- table.AddSeparator()
-
+ if instance.Flavor != nil {
+ table.AddRow("FLAVOR DESCRIPTION", utils.PtrString(instance.Flavor.Description))
+ table.AddSeparator()
+ table.AddRow("CPU", utils.PtrString(instance.Flavor.Cpu))
+ table.AddSeparator()
+ table.AddRow("RAM (GB)", utils.PtrString(instance.Flavor.Memory))
+ table.AddSeparator()
+ }
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
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 96e791c40..4fb05fb0a 100644
--- a/internal/cmd/beta/sqlserverflex/instance/describe/describe_test.go
+++ b/internal/cmd/beta/sqlserverflex/instance/describe/describe_test.go
@@ -4,23 +4,24 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &sqlserverflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -34,7 +35,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 +48,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -57,7 +60,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiGetInstanceRequest)) sqlserverflex.ApiGetInstanceRequest {
- request := testClient.GetInstance(testCtx, testProjectId, testInstanceId)
+ request := testClient.GetInstance(testCtx, testProjectId, testInstanceId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -101,7 +104,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 +112,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 +120,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 +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)
})
}
}
@@ -216,3 +172,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *sqlserverflex.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "instance as argument",
+ args: args{
+ instance: &sqlserverflex.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.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 7d89dd1d6..7fe756750 100644
--- a/internal/cmd/beta/sqlserverflex/instance/list/list.go
+++ b/internal/cmd/beta/sqlserverflex/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
@@ -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 SQLServer Flex instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 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 +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,47 +109,31 @@ 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 *sqlserverflex.APIClient) sqlserverflex.ApiListInstancesRequest {
- req := apiClient.ListInstances(ctx, model.ProjectId)
+ req := apiClient.ListInstances(ctx, model.ProjectId, model.Region)
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))
- 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 {
instance := instances[i]
- table.AddRow(*instance.Id, *instance.Name, *instance.Status)
+ table.AddRow(
+ utils.PtrString(instance.Id),
+ utils.PtrString(instance.Name),
+ utils.PtrString(instance.Status),
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,5 +141,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 12ea57b7d..d730fa056 100644
--- a/internal/cmd/beta/sqlserverflex/instance/list/list_test.go
+++ b/internal/cmd/beta/sqlserverflex/instance/list/list_test.go
@@ -4,29 +4,31 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &sqlserverflex.APIClient{}
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 +40,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 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiListInstancesRequest)) sqlserverflex.ApiListInstancesRequest {
- request := testClient.ListInstances(testCtx, testProjectId)
+ request := testClient.ListInstances(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -59,6 +62,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
@@ -77,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,
},
@@ -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)
})
}
}
@@ -186,3 +149,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ instances []sqlserverflex.InstanceListInstance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty instance in instances slice",
+ args: args{
+ instances: []sqlserverflex.InstanceListInstance{{}},
+ },
+ 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/beta/sqlserverflex/instance/update/update.go b/internal/cmd/beta/sqlserverflex/instance/update/update.go
index 67ffc6acd..81a4b5008 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"
@@ -23,6 +23,18 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex/wait"
)
+// enforce implementation of interfaces
+var (
+ _ sqlServerFlexClient = &sqlserverflex.APIClient{}
+)
+
+type sqlServerFlexClient interface {
+ PartialUpdateInstance(ctx context.Context, projectId, instanceId string, region string) sqlserverflex.ApiPartialUpdateInstanceRequest
+ GetInstanceExecute(ctx context.Context, projectId, instanceId string, region string) (*sqlserverflex.GetInstanceResponse, error)
+ ListFlavorsExecute(ctx context.Context, projectId string, region string) (*sqlserverflex.ListFlavorsResponse, error)
+ ListStoragesExecute(ctx context.Context, projectId, flavorId string, region string) (*sqlserverflex.ListStoragesResponse, error)
+}
+
const (
instanceIdArg = "INSTANCE_ID"
@@ -48,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",
@@ -65,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)
+ 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
@@ -103,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 := spinner.New(params.Printer)
s.Start("Updating instance")
- _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
+ _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx)
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)
@@ -170,32 +180,17 @@ 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
}
-type sqlServerFlexClient interface {
- PartialUpdateInstance(ctx context.Context, projectId, instanceId string) sqlserverflex.ApiPartialUpdateInstanceRequest
- GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*sqlserverflex.GetInstanceResponse, error)
- ListFlavorsExecute(ctx context.Context, projectId string) (*sqlserverflex.ListFlavorsResponse, error)
- ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*sqlserverflex.ListStoragesResponse, error)
-}
-
func buildRequest(ctx context.Context, model *inputModel, apiClient sqlServerFlexClient) (sqlserverflex.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 SQLServer Flex flavors: %w", err)
}
@@ -204,7 +199,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient sqlServerFle
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 SQLServer Flex instance: %w", err)
}
@@ -247,29 +242,15 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient sqlServerFle
}
func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *sqlserverflex.UpdateInstanceResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal update SQLServerFlex instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+ if resp == nil {
+ return fmt.Errorf("instance response is empty")
+ }
+ 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 65570cbcb..894839d93 100644
--- a/internal/cmd/beta/sqlserverflex/instance/update/update_test.go
+++ b/internal/cmd/beta/sqlserverflex/instance/update/update_test.go
@@ -5,22 +5,27 @@ import (
"fmt"
"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-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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &sqlserverflex.APIClient{}
+var testRegion = "eu01"
+
+// enforce implementation of interfaces
+var (
+ _ sqlServerFlexClient = &sqlServerFlexClientMocked{}
+)
type sqlServerFlexClientMocked struct {
listFlavorsFails bool
@@ -31,25 +36,25 @@ type sqlServerFlexClientMocked struct {
getInstanceResp *sqlserverflex.GetInstanceResponse
}
-func (c *sqlServerFlexClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId string) sqlserverflex.ApiPartialUpdateInstanceRequest {
- return testClient.PartialUpdateInstance(ctx, projectId, instanceId)
+func (c *sqlServerFlexClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId, region string) sqlserverflex.ApiPartialUpdateInstanceRequest {
+ return testClient.PartialUpdateInstance(ctx, projectId, instanceId, region)
}
-func (c *sqlServerFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*sqlserverflex.GetInstanceResponse, error) {
+func (c *sqlServerFlexClientMocked) GetInstanceExecute(_ context.Context, _, _, _ string) (*sqlserverflex.GetInstanceResponse, error) {
if c.getInstanceFails {
return nil, fmt.Errorf("get instance failed")
}
return c.getInstanceResp, nil
}
-func (c *sqlServerFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*sqlserverflex.ListStoragesResponse, error) {
+func (c *sqlServerFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, _ string) (*sqlserverflex.ListStoragesResponse, error) {
if c.listFlavorsFails {
return nil, fmt.Errorf("list storages failed")
}
return c.listStoragesResp, nil
}
-func (c *sqlServerFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*sqlserverflex.ListFlavorsResponse, error) {
+func (c *sqlServerFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*sqlserverflex.ListFlavorsResponse, error) {
if c.listFlavorsFails {
return nil, fmt.Errorf("list flavors failed")
}
@@ -72,7 +77,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,12 +88,13 @@ 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 * * *",
- versionFlag: "5.0",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ flavorIdFlag: testFlavorId,
+ instanceNameFlag: "example-name",
+ aclFlag: "0.0.0.0/0",
+ backupScheduleFlag: "0 0 * * *",
+ versionFlag: "5.0",
}
for _, mod := range mods {
mod(flagValues)
@@ -99,6 +106,7 @@ func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -113,6 +121,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -129,7 +138,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiPartialUpdateInstanceRequest)) sqlserverflex.ApiPartialUpdateInstanceRequest {
- request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId)
+ request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId, testRegion)
request = request.PartialUpdateInstancePayload(sqlserverflex.PartialUpdateInstancePayload{})
for _, mod := range mods {
mod(&request)
@@ -197,7 +206,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,
},
@@ -205,7 +214,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,
},
@@ -213,7 +222,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,
},
@@ -274,7 +283,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)
@@ -369,7 +378,7 @@ func TestBuildRequest(t *testing.T) {
},
},
},
- expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId).
+ expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId, testRegion).
PartialUpdateInstancePayload(sqlserverflex.PartialUpdateInstancePayload{
FlavorId: utils.Ptr(testFlavorId),
}),
@@ -390,7 +399,7 @@ func TestBuildRequest(t *testing.T) {
},
},
},
- expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId).
+ expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId, testRegion).
PartialUpdateInstancePayload(sqlserverflex.PartialUpdateInstancePayload{
FlavorId: utils.Ptr(testFlavorId),
}),
@@ -486,3 +495,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ instanceLabel string
+ resp *sqlserverflex.UpdateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "instance as argument",
+ args: args{
+ model: fixtureRequiredInputModel(),
+ resp: &sqlserverflex.UpdateInstanceResponse{},
+ },
+ 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.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/sqlserverflex/options/options.go b/internal/cmd/beta/sqlserverflex/options/options.go
index 3b3ef5dbf..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"
@@ -18,6 +18,20 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
+// enforce implementation of interfaces
+var (
+ _ sqlServerFlexOptionsClient = &sqlserverflex.APIClient{}
+)
+
+type sqlServerFlexOptionsClient interface {
+ ListFlavorsExecute(ctx context.Context, projectId string, region string) (*sqlserverflex.ListFlavorsResponse, error)
+ ListVersionsExecute(ctx context.Context, projectId string, region string) (*sqlserverflex.ListVersionsResponse, error)
+ ListStoragesExecute(ctx context.Context, projectId, flavorId string, region string) (*sqlserverflex.ListStoragesResponse, error)
+ ListRolesExecute(ctx context.Context, projectId string, instanceId string, region string) (*sqlserverflex.ListRolesResponse, error)
+ ListCollationsExecute(ctx context.Context, projectId string, instanceId string, region string) (*sqlserverflex.ListCollationsResponse, error)
+ ListCompatibilityExecute(ctx context.Context, projectId string, instanceId string, region string) (*sqlserverflex.ListCompatibilityResponse, error)
+}
+
const (
flavorsFlag = "flavors"
versionsFlag = "versions"
@@ -73,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",
@@ -95,19 +109,19 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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)
}
@@ -130,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)
@@ -175,27 +189,10 @@ 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
}
-type sqlServerFlexOptionsClient interface {
- ListFlavorsExecute(ctx context.Context, projectId string) (*sqlserverflex.ListFlavorsResponse, error)
- ListVersionsExecute(ctx context.Context, projectId string) (*sqlserverflex.ListVersionsResponse, error)
- ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*sqlserverflex.ListStoragesResponse, error)
- ListRolesExecute(ctx context.Context, projectId string, instanceId string) (*sqlserverflex.ListRolesResponse, error)
- ListCollationsExecute(ctx context.Context, projectId string, instanceId string) (*sqlserverflex.ListCollationsResponse, error)
- ListCompatibilityExecute(ctx context.Context, projectId string, instanceId string) (*sqlserverflex.ListCompatibilityResponse, error)
-}
-
func buildAndExecuteRequest(ctx context.Context, p *print.Printer, model *inputModel, apiClient sqlServerFlexOptionsClient) error {
var flavors *sqlserverflex.ListFlavorsResponse
var versions *sqlserverflex.ListVersionsResponse
@@ -206,37 +203,37 @@ 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 SQL Server 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 SQL Server 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 SQL Server Flex storages: %w", err)
}
}
if model.UserRoles {
- userRoles, err = apiClient.ListRolesExecute(ctx, model.ProjectId, *model.InstanceId)
+ userRoles, err = apiClient.ListRolesExecute(ctx, model.ProjectId, *model.InstanceId, model.Region)
if err != nil {
return fmt.Errorf("get SQL Server Flex user roles: %w", err)
}
}
if model.DBCollations {
- dbCollations, err = apiClient.ListCollationsExecute(ctx, model.ProjectId, *model.InstanceId)
+ dbCollations, err = apiClient.ListCollationsExecute(ctx, model.ProjectId, *model.InstanceId, model.Region)
if err != nil {
return fmt.Errorf("get SQL Server Flex DB collations: %w", err)
}
}
if model.DBCompatibilities {
- dbCompatibilities, err = apiClient.ListCompatibilityExecute(ctx, model.ProjectId, *model.InstanceId)
+ dbCompatibilities, err = apiClient.ListCompatibilityExecute(ctx, model.ProjectId, *model.InstanceId, model.Region)
if err != nil {
return fmt.Errorf("get SQL Server Flex DB compatibilities: %w", err)
}
@@ -278,62 +275,38 @@ 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))
+ 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 := ""
- if model.Flavors {
- content += renderFlavors(*options.Flavors)
- }
- if model.Versions {
- content += renderVersions(*options.Versions)
- }
- if model.Storages {
- content += renderStorages(options.Storages.Storages)
- }
- if model.UserRoles {
- content += renderUserRoles(options.UserRoles)
- }
- if model.DBCompatibilities {
- content += renderDBCompatibilities(options.DBCompatibilities)
- }
- // Rendered at last because table is very long
- if model.DBCollations {
- content += renderDBCollations(options.DBCollations)
- }
-
- err := p.PagerDisplay(content)
- if err != nil {
- return fmt.Errorf("display output: %w", err)
- }
-
- return nil
-}
-
-func renderFlavors(flavors []sqlserverflex.InstanceFlavorEntry) string {
- if len(flavors) == 0 {
- return ""
- }
-
+func buildFlavorsTable(flavors []sqlserverflex.InstanceFlavorEntry) tables.Table {
table := tables.NewTable()
table.SetTitle("Flavors")
table.SetHeader("ID", "CPU", "MEMORY", "DESCRIPTION", "VALID INSTANCE TYPES")
@@ -341,14 +314,10 @@ func renderFlavors(flavors []sqlserverflex.InstanceFlavorEntry) string {
f := flavors[i]
table.AddRow(*f.Id, *f.Cpu, *f.Memory, *f.Description, *f.Categories)
}
- return table.Render()
+ return table
}
-func renderVersions(versions []string) string {
- if len(versions) == 0 {
- return ""
- }
-
+func buildVersionsTable(versions []string) tables.Table {
table := tables.NewTable()
table.SetTitle("Versions")
table.SetHeader("VERSION")
@@ -356,64 +325,48 @@ func renderVersions(versions []string) string {
v := versions[i]
table.AddRow(v)
}
- return table.Render()
+ return table
}
-func renderStorages(resp *sqlserverflex.ListStoragesResponse) string {
- if resp.StorageClasses == nil || len(*resp.StorageClasses) == 0 {
- return ""
- }
- storageClasses := *resp.StorageClasses
-
+func buildStoragesTable(storagesResp sqlserverflex.ListStoragesResponse) tables.Table {
+ storages := *storagesResp.StorageClasses
table := tables.NewTable()
table.SetTitle("Storages")
table.SetHeader("MINIMUM", "MAXIMUM", "STORAGE CLASS")
- for i := range storageClasses {
- sc := storageClasses[i]
- table.AddRow(*resp.StorageRange.Min, *resp.StorageRange.Max, sc)
+ for i := range storages {
+ sc := storages[i]
+ table.AddRow(*storagesResp.StorageRange.Min, *storagesResp.StorageRange.Max, sc)
}
table.EnableAutoMergeOnColumns(1, 2, 3)
- return table.Render()
+ return table
}
-func renderUserRoles(roles *instanceUserRoles) string {
- if len(roles.UserRoles) == 0 {
- return ""
- }
-
+func buildUserRoles(roles *instanceUserRoles) tables.Table {
table := tables.NewTable()
table.SetTitle("User Roles")
table.SetHeader("ROLE")
for i := range roles.UserRoles {
table.AddRow(roles.UserRoles[i])
}
- return table.Render()
+ return table
}
-func renderDBCollations(dbCollations *instanceDBCollations) string {
- if len(dbCollations.DBCollations) == 0 {
- return ""
- }
-
+func buildDBCollationsTable(dbCollations []sqlserverflex.MssqlDatabaseCollation) tables.Table {
table := tables.NewTable()
table.SetTitle("DB Collations")
table.SetHeader("NAME", "DESCRIPTION")
- for i := range dbCollations.DBCollations {
- table.AddRow(*dbCollations.DBCollations[i].CollationName, *dbCollations.DBCollations[i].Description)
+ for i := range dbCollations {
+ table.AddRow(dbCollations[i].CollationName, dbCollations[i].Description)
}
- return table.Render()
+ return table
}
-func renderDBCompatibilities(dbCompatibilities *instanceDBCompatibilities) string {
- if len(dbCompatibilities.DBCompatibilities) == 0 {
- return ""
- }
-
+func buildDBCompatibilitiesTable(dbCompatibilities []sqlserverflex.MssqlDatabaseCompatibility) tables.Table {
table := tables.NewTable()
table.SetTitle("DB Compatibilities")
table.SetHeader("COMPATIBILITY LEVEL", "DESCRIPTION")
- for i := range dbCompatibilities.DBCompatibilities {
- table.AddRow(*dbCompatibilities.DBCompatibilities[i].CompatibilityLevel, *dbCompatibilities.DBCompatibilities[i].Description)
+ for i := range dbCompatibilities {
+ table.AddRow(dbCompatibilities[i].CompatibilityLevel, dbCompatibilities[i].Description)
}
- return table.Render()
+ return table
}
diff --git a/internal/cmd/beta/sqlserverflex/options/options_test.go b/internal/cmd/beta/sqlserverflex/options/options_test.go
index 02aff416b..3a43e1668 100644
--- a/internal/cmd/beta/sqlserverflex/options/options_test.go
+++ b/internal/cmd/beta/sqlserverflex/options/options_test.go
@@ -5,12 +5,13 @@ import (
"fmt"
"testing"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "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/testutils"
"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/sqlserverflex"
)
@@ -19,6 +20,11 @@ type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testInstanceId = uuid.NewString()
+// enforce implementation of interfaces
+var (
+ _ sqlServerFlexOptionsClient = &sqlServerFlexClientMocked{}
+)
+
type sqlServerFlexClientMocked struct {
listFlavorsFails bool
listVersionsFails bool
@@ -35,7 +41,7 @@ type sqlServerFlexClientMocked struct {
listDBCompatibilitiesCalled bool
}
-func (c *sqlServerFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*sqlserverflex.ListFlavorsResponse, error) {
+func (c *sqlServerFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*sqlserverflex.ListFlavorsResponse, error) {
c.listFlavorsCalled = true
if c.listFlavorsFails {
return nil, fmt.Errorf("list flavors failed")
@@ -45,7 +51,7 @@ func (c *sqlServerFlexClientMocked) ListFlavorsExecute(_ context.Context, _ stri
}), nil
}
-func (c *sqlServerFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*sqlserverflex.ListVersionsResponse, error) {
+func (c *sqlServerFlexClientMocked) ListVersionsExecute(_ context.Context, _, _ string) (*sqlserverflex.ListVersionsResponse, error) {
c.listVersionsCalled = true
if c.listVersionsFails {
return nil, fmt.Errorf("list versions failed")
@@ -55,7 +61,7 @@ func (c *sqlServerFlexClientMocked) ListVersionsExecute(_ context.Context, _ str
}), nil
}
-func (c *sqlServerFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*sqlserverflex.ListStoragesResponse, error) {
+func (c *sqlServerFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, _ string) (*sqlserverflex.ListStoragesResponse, error) {
c.listStoragesCalled = true
if c.listStoragesFails {
return nil, fmt.Errorf("list storages failed")
@@ -69,7 +75,7 @@ func (c *sqlServerFlexClientMocked) ListStoragesExecute(_ context.Context, _, _
}), nil
}
-func (c *sqlServerFlexClientMocked) ListRolesExecute(_ context.Context, _, _ string) (*sqlserverflex.ListRolesResponse, error) {
+func (c *sqlServerFlexClientMocked) ListRolesExecute(_ context.Context, _, _, _ string) (*sqlserverflex.ListRolesResponse, error) {
c.listUserRolesCalled = true
if c.listUserRolesFails {
return nil, fmt.Errorf("list roles failed")
@@ -79,7 +85,7 @@ func (c *sqlServerFlexClientMocked) ListRolesExecute(_ context.Context, _, _ str
}), nil
}
-func (c *sqlServerFlexClientMocked) ListCollationsExecute(_ context.Context, _, _ string) (*sqlserverflex.ListCollationsResponse, error) {
+func (c *sqlServerFlexClientMocked) ListCollationsExecute(_ context.Context, _, _, _ string) (*sqlserverflex.ListCollationsResponse, error) {
c.listDBCollationsCalled = true
if c.listDBCollationsFails {
return nil, fmt.Errorf("list collations failed")
@@ -89,7 +95,7 @@ func (c *sqlServerFlexClientMocked) ListCollationsExecute(_ context.Context, _,
}), nil
}
-func (c *sqlServerFlexClientMocked) ListCompatibilityExecute(_ context.Context, _, _ string) (*sqlserverflex.ListCompatibilityResponse, error) {
+func (c *sqlServerFlexClientMocked) ListCompatibilityExecute(_ context.Context, _, _, _ string) (*sqlserverflex.ListCompatibilityResponse, error) {
c.listDBCompatibilitiesCalled = true
if c.listDBCompatibilitiesFails {
return nil, fmt.Errorf("list compatibilities failed")
@@ -153,6 +159,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
@@ -258,46 +265,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)
})
}
}
@@ -468,7 +436,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,
@@ -502,3 +470,50 @@ func TestBuildAndExecuteRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ flavors *sqlserverflex.ListFlavorsResponse
+ versions *sqlserverflex.ListVersionsResponse
+ storages *sqlserverflex.ListStoragesResponse
+ userRoles *sqlserverflex.ListRolesResponse
+ dbCollations *sqlserverflex.ListCollationsResponse
+ dbCompatibilities *sqlserverflex.ListCompatibilityResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty - only model",
+ args: args{
+ model: fixtureInputModelAllFalse(),
+ },
+ wantErr: false,
+ },
+ {
+ name: "all input set",
+ args: args{
+ model: fixtureInputModelAllTrue(),
+ flavors: &sqlserverflex.ListFlavorsResponse{Flavors: &[]sqlserverflex.InstanceFlavorEntry{}},
+ versions: &sqlserverflex.ListVersionsResponse{Versions: &[]string{}},
+ storages: &sqlserverflex.ListStoragesResponse{StorageClasses: &[]string{}},
+ userRoles: &sqlserverflex.ListRolesResponse{Roles: &[]string{}},
+ dbCollations: &sqlserverflex.ListCollationsResponse{Collations: &[]sqlserverflex.MssqlDatabaseCollation{}},
+ dbCompatibilities: &sqlserverflex.ListCompatibilityResponse{Compatibilities: &[]sqlserverflex.MssqlDatabaseCompatibility{}},
+ },
+ 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.flavors, tt.args.versions, tt.args.storages, tt.args.userRoles, tt.args.dbCollations, tt.args.dbCompatibilities); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 305845dbd..1f3887390 100644
--- a/internal/cmd/beta/sqlserverflex/user/create/create.go
+++ b/internal/cmd/beta/sqlserverflex/user/create/create.go
@@ -2,10 +2,11 @@ package create
import (
"context"
- "encoding/json"
"fmt"
+ "strings"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
- "github.com/goccy/go-yaml"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
@@ -15,6 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"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"
)
@@ -32,51 +34,51 @@ 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",
- Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s",
+ Long: fmt.Sprintf("%s\n\n%s\n%s\n%s\n%s\n\n%s\n%s",
"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",
),
Example: examples.Build(
examples.NewExample(
`Create a SQLServer Flex user for instance with ID "xxx" and specify the username, role and database`,
- "$ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles my-role --database my-database"),
+ `$ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "##STACKIT_DatabaseManager##"`),
examples.NewExample(
`Create a SQLServer Flex user for instance with ID "xxx", specifying multiple roles`,
- `$ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "my-role-1,my-role-2"`),
+ `$ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "##STACKIT_LoginManager##,##STACKIT_DatabaseManager##"`),
),
Args: args.NoArgs,
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)
+ 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
@@ -87,7 +89,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
user := resp.Item
- return outputResult(p, model, instanceLabel, user)
+ return outputResult(params.Printer, model, instanceLabel, user)
},
}
@@ -100,11 +102,11 @@ func configureFlags(cmd *cobra.Command) {
cmd.Flags().String(usernameFlag, "", "Username of the user")
cmd.Flags().StringSlice(rolesFlag, []string{}, "Roles of the user")
- err := flags.MarkFlagsRequired(cmd, instanceIdFlag, usernameFlag)
+ err := flags.MarkFlagsRequired(cmd, instanceIdFlag, usernameFlag, rolesFlag)
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,59 +119,30 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverflex.APIClient) sqlserverflex.ApiCreateUserRequest {
- req := apiClient.CreateUser(ctx, model.ProjectId, model.InstanceId)
-
- var roles []sqlserverflex.Role
- if model.Roles != nil {
- for _, r := range *model.Roles {
- roles = append(roles, sqlserverflex.Role(r))
- }
- }
+ req := apiClient.CreateUser(ctx, model.ProjectId, model.InstanceId, model.Region)
req = req.CreateUserPayload(sqlserverflex.CreateUserPayload{
Username: model.Username,
- Roles: &roles,
+ Roles: model.Roles,
})
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, user *sqlserverflex.User) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal SQLServer Flex user: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
- p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, *user.Id)
- p.Outputf("Username: %s\n", *user.Username)
- p.Outputf("Password: %s\n", *user.Password)
+func outputResult(p *print.Printer, model *inputModel, instanceLabel string, user *sqlserverflex.SingleUser) error {
+ if user == nil {
+ return fmt.Errorf("user response is empty")
+ }
+ 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))
if user.Roles != nil && len(*user.Roles) != 0 {
- p.Outputf("Roles: %v\n", *user.Roles)
+ p.Outputf("Roles: [%v]\n", strings.Join(*user.Roles, ", "))
}
if user.Host != nil && *user.Host != "" {
p.Outputf("Host: %s\n", *user.Host)
@@ -182,5 +155,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 e0583f9fc..d8e9a8836 100644
--- a/internal/cmd/beta/sqlserverflex/user/create/create_test.go
+++ b/internal/cmd/beta/sqlserverflex/user/create/create_test.go
@@ -4,32 +4,33 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &sqlserverflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
+var testRegion = "eu01"
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
- projectIdFlag: testProjectId,
- instanceIdFlag: testInstanceId,
- usernameFlag: "johndoe",
- rolesFlag: "read",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
+ usernameFlag: "johndoe",
+ rolesFlag: "read",
}
for _, mod := range mods {
mod(flagValues)
@@ -41,6 +42,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -54,10 +56,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiCreateUserRequest)) sqlserverflex.ApiCreateUserRequest {
- request := testClient.CreateUser(testCtx, testProjectId, testInstanceId)
+ request := testClient.CreateUser(testCtx, testProjectId, testInstanceId, testRegion)
request = request.CreateUserPayload(sqlserverflex.CreateUserPayload{
Username: utils.Ptr("johndoe"),
- Roles: utils.Ptr([]sqlserverflex.Role{"read"}),
+ Roles: utils.Ptr([]string{"read"}),
})
for _, mod := range mods {
@@ -69,6 +71,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
@@ -92,10 +95,7 @@ func TestParseInput(t *testing.T) {
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
delete(flagValues, rolesFlag)
}),
- isValid: true,
- expectedModel: fixtureInputModel(func(model *inputModel) {
- model.Roles = nil
- }),
+ isValid: false,
},
{
description: "no values",
@@ -105,21 +105,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,48 +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)
- }
-
- 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)
})
}
}
@@ -204,7 +163,7 @@ func TestBuildRequest(t *testing.T) {
model.Username = nil
}),
expectedRequest: fixtureRequest().CreateUserPayload(sqlserverflex.CreateUserPayload{
- Roles: utils.Ptr([]sqlserverflex.Role{"read"}),
+ Roles: utils.Ptr([]string{"read"}),
}),
},
}
@@ -223,3 +182,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ instanceLabel string
+ user *sqlserverflex.SingleUser
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "user as argument",
+ args: args{
+ model: fixtureInputModel(),
+ user: &sqlserverflex.SingleUser{},
+ },
+ 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.instanceLabel, tt.args.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/sqlserverflex/user/delete/delete.go b/internal/cmd/beta/sqlserverflex/user/delete/delete.go
index 98d609579..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)
+ 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)
+ 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,19 +113,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 *sqlserverflex.APIClient) sqlserverflex.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/beta/sqlserverflex/user/delete/delete_test.go b/internal/cmd/beta/sqlserverflex/user/delete/delete_test.go
index fcc8a26d6..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"
@@ -13,8 +13,6 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,6 +20,7 @@ var testClient = &sqlserverflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testUserId = "my-user-id"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -35,8 +34,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 +48,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 fixtureRequest(mods ...func(request *sqlserverflex.ApiDeleteUserRequest)) sqlserverflex.ApiDeleteUserRequest {
- request := testClient.DeleteUser(testCtx, testProjectId, testInstanceId, testUserId)
+ request := testClient.DeleteUser(testCtx, testProjectId, testInstanceId, testUserId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -104,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,
},
@@ -112,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,
},
@@ -120,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,
},
@@ -152,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 d12b232b6..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"
@@ -15,6 +15,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
@@ -33,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",
@@ -53,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
}
@@ -71,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)
},
}
@@ -100,53 +101,32 @@ 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 *sqlserverflex.APIClient) sqlserverflex.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 sqlserverflex.InstanceResponseUser) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal SQLServer Flex user: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, user *sqlserverflex.UserResponseUser) error {
+ if user == nil {
+ return fmt.Errorf("user response is empty")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, user, func() error {
table := tables.NewTable()
- table.AddRow("ID", *user.Id)
+ table.AddRow("ID", utils.PtrString(user.Id))
table.AddSeparator()
- table.AddRow("USERNAME", *user.Username)
+ table.AddRow("USERNAME", utils.PtrString(user.Username))
if user.Roles != nil && len(*user.Roles) != 0 {
table.AddSeparator()
table.AddRow("ROLES", strings.Join(*user.Roles, "\n"))
}
- if user.Database != nil && *user.Database != "" {
+ if user.DefaultDatabase != nil && *user.DefaultDatabase != "" {
table.AddSeparator()
- table.AddRow("DATABASE", *user.Database)
+ table.AddRow("DATABASE", *user.DefaultDatabase)
}
if user.Host != nil && *user.Host != "" {
table.AddSeparator()
@@ -163,5 +143,5 @@ func outputResult(p *print.Printer, outputFormat string, user sqlserverflex.Inst
}
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 3d5bbb3e5..77123e300 100644
--- a/internal/cmd/beta/sqlserverflex/user/describe/describe_test.go
+++ b/internal/cmd/beta/sqlserverflex/user/describe/describe_test.go
@@ -4,17 +4,17 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,6 +22,7 @@ var testClient = &sqlserverflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testUserId = "my-user-id"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -35,8 +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,
- instanceIdFlag: testInstanceId,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
}
for _, mod := range mods {
mod(flagValues)
@@ -48,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -60,7 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiGetUserRequest)) sqlserverflex.ApiGetUserRequest {
- request := testClient.GetUser(testCtx, testProjectId, testInstanceId, testUserId)
+ request := testClient.GetUser(testCtx, testProjectId, testInstanceId, testUserId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -104,7 +107,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 +115,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 +123,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,54 +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.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,3 +187,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ user *sqlserverflex.UserResponseUser
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only user as argument",
+ args: args{
+ user: &sqlserverflex.UserResponseUser{},
+ },
+ 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.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/sqlserverflex/user/list/list.go b/internal/cmd/beta/sqlserverflex/user/list/list.go
index 3ca173b24..24a216bbd 100644
--- a/internal/cmd/beta/sqlserverflex/user/list/list.go
+++ b/internal/cmd/beta/sqlserverflex/user/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ 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/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
@@ -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 SQLServer Flex users of an instance",
@@ -51,13 +51,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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 +68,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)
- 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 +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{}
@@ -120,47 +117,30 @@ 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 *sqlserverflex.APIClient) sqlserverflex.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 []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))
- 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 {
user := users[i]
- table.AddRow(*user.Id, *user.Username)
+ table.AddRow(
+ utils.PtrString(user.Id),
+ utils.PtrString(user.Username),
+ )
}
err := table.Display(p)
if err != nil {
@@ -168,5 +148,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 94ef7a475..fd7e31df7 100644
--- a/internal/cmd/beta/sqlserverflex/user/list/list_test.go
+++ b/internal/cmd/beta/sqlserverflex/user/list/list_test.go
@@ -4,19 +4,18 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -24,11 +23,14 @@ var testClient = &sqlserverflex.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{
- projectIdFlag: testProjectId,
- instanceIdFlag: testInstanceId,
- limitFlag: "10",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
+ limitFlag: "10",
}
for _, mod := range mods {
mod(flagValues)
@@ -40,6 +42,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 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiListUsersRequest)) sqlserverflex.ApiListUsersRequest {
- request := testClient.ListUsers(testCtx, testProjectId, testInstanceId)
+ request := testClient.ListUsers(testCtx, testProjectId, testInstanceId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -62,6 +65,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
@@ -80,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,
},
@@ -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)
})
}
}
@@ -203,3 +166,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ users []sqlserverflex.InstanceListUser
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty user in the users slice",
+ args: args{
+ users: []sqlserverflex.InstanceListUser{{}},
+ },
+ 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.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 43802e763..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"
@@ -14,6 +14,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"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/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
@@ -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 SQLServer 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 := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId)
+ 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)
+ 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
@@ -86,7 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("reset SQLServer Flex user password: %w", err)
}
- return outputResult(p, model, userLabel, instanceLabel, user.Item)
+ return outputResult(params.Printer, model.OutputFormat, userLabel, instanceLabel, user.Item)
},
}
@@ -115,48 +114,27 @@ 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 *sqlserverflex.APIClient) sqlserverflex.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
}
-func outputResult(p *print.Printer, model *inputModel, userLabel, instanceLabel string, user *sqlserverflex.User) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal SQLServer Flex reset password: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, userLabel, instanceLabel string, user *sqlserverflex.SingleUser) error {
+ if user == nil {
+ return fmt.Errorf("single user response is empty")
+ }
- 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", *user.Username)
- p.Outputf("New password: %s\n", *user.Password)
+ p.Outputf("Username: %s\n", utils.PtrString(user.Username))
+ p.Outputf("New password: %s\n", utils.PtrString(user.Password))
if user.Uri != nil && *user.Uri != "" {
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 48d8caa32..921e39758 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,17 +4,17 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,6 +22,7 @@ var testClient = &sqlserverflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testUserId = "my-user-id"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -35,8 +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,
- instanceIdFlag: testInstanceId,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
}
for _, mod := range mods {
mod(flagValues)
@@ -48,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -60,7 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *sqlserverflex.ApiResetUserRequest)) sqlserverflex.ApiResetUserRequest {
- request := testClient.ResetUser(testCtx, testProjectId, testInstanceId, testUserId)
+ request := testClient.ResetUser(testCtx, testProjectId, testInstanceId, testUserId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -104,7 +107,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 +115,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 +123,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,54 +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.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,3 +187,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ userLabel string
+ instanceLabel string
+ user *sqlserverflex.SingleUser
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only user as argument",
+ args: args{
+ user: &sqlserverflex.SingleUser{},
+ },
+ 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.userLabel, tt.args.instanceLabel, tt.args.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 8ba6160f4..71e115b29 100644
--- a/internal/cmd/config/list/list.go
+++ b/internal/cmd/config/list/list.go
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
"slices"
"sort"
"strconv"
@@ -25,7 +27,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",
@@ -47,17 +49,17 @@ func NewCmd(p *print.Printer) *cobra.Command {
`List your active configuration in a json format`,
"$ stackit config list --output-format json"),
),
- RunE: func(cmd *cobra.Command, args []string) error {
+ 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
@@ -84,7 +86,7 @@ func outputResult(p *print.Printer, outputFormat string, configData map[string]a
p.Outputln(string(details))
return nil
case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(configData, yaml.IndentSequence(true))
+ details, err := yaml.MarshalWithOptions(configData, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
if err != nil {
return fmt.Errorf("marshal config list: %w", err)
}
diff --git a/internal/cmd/config/list/list_test.go b/internal/cmd/config/list/list_test.go
new file mode 100644
index 000000000..2129c29d9
--- /dev/null
+++ b/internal/cmd/config/list/list_test.go
@@ -0,0 +1,37 @@
+package list
+
+import (
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+)
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ configData map[string]any
+ activeProfile string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.configData, tt.args.activeProfile); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 f93a6163f..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,28 +62,26 @@ 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)
}
- err = auth.DeleteProfileFromKeyring(model.Profile)
+ err = auth.DeleteProfileAuth(model.Profile)
if err != nil {
- return fmt.Errorf("delete profile from keyring: %w", err)
+ 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
new file mode 100644
index 000000000..631ca975c
--- /dev/null
+++ b/internal/cmd/config/profile/export/export.go
@@ -0,0 +1,91 @@
+package export
+
+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"
+ "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/spf13/cobra"
+)
+
+const (
+ profileNameArg = "PROFILE_NAME"
+
+ filePathFlag = "file-path"
+
+ configFileExtension = "json"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ProfileName string
+ FilePath string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("export %s", profileNameArg),
+ Short: "Exports a CLI configuration profile",
+ Long: "Exports a CLI configuration profile.",
+ Example: examples.Build(
+ examples.NewExample(
+ `Export a profile with name "PROFILE_NAME" to a file in your current directory`,
+ "$ stackit config profile export PROFILE_NAME",
+ ),
+ examples.NewExample(
+ `Export a profile with name "PROFILE_NAME"" to a specific file path FILE_PATH`,
+ "$ stackit config profile export PROFILE_NAME --file-path FILE_PATH",
+ ),
+ ),
+ Args: args.SingleArg(profileNameArg, nil),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ model, err := parseInput(params.Printer, cmd, args)
+ if err != nil {
+ return err
+ }
+
+ err = config.ExportProfile(params.Printer, model.ProfileName, model.FilePath)
+ if err != nil {
+ return fmt.Errorf("could not export profile: %w", err)
+ }
+
+ params.Printer.Info("Exported profile %q to %q\n", model.ProfileName, model.FilePath)
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(filePathFlag, "f", "", "If set, writes the config to the given file path. If unset, writes the config to you current directory with the name of the profile. E.g. '--file-path ~/my-config.json'")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ profileName := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ProfileName: profileName,
+ FilePath: flags.FlagToStringValue(p, cmd, filePathFlag),
+ }
+
+ // If filePath contains does not contain a file name, then add a default name
+ if model.FilePath == "" {
+ exportFileName := fmt.Sprintf("%s.%s", model.ProfileName, configFileExtension)
+ model.FilePath = filepath.Join(model.FilePath, exportFileName)
+ }
+
+ 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
new file mode 100644
index 000000000..dc67621da
--- /dev/null
+++ b/internal/cmd/config/profile/export/export_test.go
@@ -0,0 +1,105 @@
+package export
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+)
+
+const (
+ testProfileArg = "default"
+ testExportPath = "/tmp/stackit-profiles/" + testProfileArg + ".json"
+)
+
+func fixtureArgValues(mods ...func(args []string)) []string {
+ args := []string{
+ testProfileArg,
+ }
+ for _, mod := range mods {
+ mod(args)
+ }
+ return args
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ filePathFlag: testExportPath,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ ProfileName: testProfileArg,
+ FilePath: testExportPath,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+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{},
+ isValid: false,
+ },
+ {
+ description: "no args",
+ argsValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flags",
+ argsValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.FilePath = fmt.Sprintf("%s.json", testProfileArg)
+ }),
+ },
+ {
+ description: "custom file-path without file extension",
+ argsValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(
+ func(flagValues map[string]string) {
+ flagValues[filePathFlag] = "./my-exported-config"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.FilePath = "./my-exported-config"
+ }),
+ },
+ }
+
+ 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)
+ })
+ }
+}
diff --git a/internal/cmd/config/profile/import/import.go b/internal/cmd/config/profile/import/import.go
new file mode 100644
index 000000000..aede97304
--- /dev/null
+++ b/internal/cmd/config/profile/import/import.go
@@ -0,0 +1,99 @@
+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"
+ "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/types"
+)
+
+const (
+ nameFlag = "name"
+ configFlag = "config"
+ noSetFlag = "no-set"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ProfileName string
+ Config string
+ NoSet bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "import",
+ Short: "Imports a CLI configuration profile",
+ Long: "Imports a CLI configuration profile.",
+ Example: examples.Build(
+ examples.NewExample(
+ `Import a config with name "PROFILE_NAME" from file "./config.json"`,
+ "$ stackit config profile import --name PROFILE_NAME --config `@./config.json`",
+ ),
+ examples.NewExample(
+ `Import a config with name "PROFILE_NAME" from file "./config.json" and do not set as active`,
+ "$ stackit config profile import --name PROFILE_NAME --config `@./config.json` --no-set",
+ ),
+ ),
+ Args: args.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ model, err := parseInput(params.Printer, cmd, args)
+ if err != nil {
+ return err
+ }
+
+ err = config.ImportProfile(params.Printer, model.ProfileName, model.Config, !model.NoSet)
+ if err != nil {
+ return err
+ }
+
+ params.Printer.Info("Successfully imported profile %q\n", model.ProfileName)
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(nameFlag, "", "Profile name")
+ cmd.Flags().VarP(flags.ReadFromFileFlag(), configFlag, "c", "File where configuration will be imported from")
+ cmd.Flags().Bool(noSetFlag, false, "Set the imported profile not as active")
+
+ cobra.CheckErr(cmd.MarkFlagRequired(nameFlag))
+ cobra.CheckErr(cmd.MarkFlagRequired(configFlag))
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := &inputModel{
+ GlobalFlagModel: globalFlags,
+ ProfileName: flags.FlagToStringValue(p, cmd, nameFlag),
+ Config: flags.FlagToStringValue(p, cmd, configFlag),
+ NoSet: flags.FlagToBoolValue(p, cmd, noSetFlag),
+ }
+
+ if model.Config == "" {
+ return nil, &errors.FlagValidationError{
+ Flag: configFlag,
+ Details: "must not be empty",
+ }
+ }
+
+ if model.ProfileName == "" {
+ return nil, &errors.FlagValidationError{
+ Flag: nameFlag,
+ Details: "must not be empty",
+ }
+ }
+
+ 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
new file mode 100644
index 000000000..e676f1b14
--- /dev/null
+++ b/internal/cmd/config/profile/import/import_test.go
@@ -0,0 +1,79 @@
+package importProfile
+
+import (
+ _ "embed"
+ "strconv"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+)
+
+const testProfile = "test-profile"
+const testConfig = "@./template/profile.json"
+const testNoSet = false
+
+//go:embed template/profile.json
+var testConfigContent string
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ nameFlag: testProfile,
+ configFlag: testConfig,
+ noSetFlag: strconv.FormatBool(testNoSet),
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ ProfileName: testProfile,
+ Config: testConfigContent,
+ NoSet: testNoSet,
+ }
+ 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 flags",
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "invalid path",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[configFlag] = "@./template/invalid-file"
+ }),
+ 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/config/profile/import/template/profile.json b/internal/cmd/config/profile/import/template/profile.json
new file mode 100644
index 000000000..ed2702e7e
--- /dev/null
+++ b/internal/cmd/config/profile/import/template/profile.json
@@ -0,0 +1,35 @@
+{
+ "allowed_url_domain": "stackit.cloud",
+ "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": "",
+ "load_balancer_custom_endpoint": "",
+ "logme_custom_endpoint": "",
+ "mariadb_custom_endpoint": "",
+ "mongodbflex_custom_endpoint": "",
+ "object_storage_custom_endpoint": "",
+ "observability_custom_endpoint": "",
+ "opensearch_custom_endpoint": "",
+ "output_format": "",
+ "postgresflex_custom_endpoint": "",
+ "project_id": "",
+ "project_name": "",
+ "rabbitmq_custom_endpoint": "",
+ "redis_custom_endpoint": "",
+ "resource_manager_custom_endpoint": "",
+ "runcommand_custom_endpoint": "",
+ "secrets_manager_custom_endpoint": "",
+ "serverbackup_custom_endpoint": "",
+ "service_account_custom_endpoint": "",
+ "service_enablement_custom_endpoint": "",
+ "session_time_limit": "12h",
+ "sfs_custom_endpoint": "",
+ "ske_custom_endpoint": "",
+ "sqlserverflex_custom_endpoint": "",
+ "token_custom_endpoint": "",
+ "verbosity": "info"
+}
diff --git a/internal/cmd/config/profile/list/list.go b/internal/cmd/config/profile/list/list.go
index 7f594d1e1..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",
@@ -34,8 +34,8 @@ func NewCmd(p *print.Printer) *cobra.Command {
`List the configuration profiles in a json format`,
"$ stackit config profile list --output-format json"),
),
- RunE: func(cmd *cobra.Command, args []string) error {
- model := parseInput(p, cmd)
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ 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))
- 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
new file mode 100644
index 000000000..e9d93b147
--- /dev/null
+++ b/internal/cmd/config/profile/list/list_test.go
@@ -0,0 +1,36 @@
+package list
+
+import (
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+)
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ profiles []profileInfo
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.profiles); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/config/profile/profile.go b/internal/cmd/config/profile/profile.go
index 3a4233ec4..ab13f07cc 100644
--- a/internal/cmd/config/profile/profile.go
+++ b/internal/cmd/config/profile/profile.go
@@ -3,19 +3,22 @@ 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"
+ importProfile "github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/import"
"github.com/stackitcloud/stackit-cli/internal/cmd/config/profile/list"
"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",
@@ -28,14 +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))
+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 f767cbfe4..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",
@@ -26,21 +28,21 @@ func NewCmd(p *print.Printer) *cobra.Command {
`Unset the currently active configuration profile. The default profile will be used.`,
"$ stackit config profile unset"),
),
- RunE: func(cmd *cobra.Command, args []string) error {
- err := config.UnsetProfile(p)
+ RunE: func(_ *cobra.Command, _ []string) error {
+ 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 f2ad14ecd..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"
@@ -17,25 +19,40 @@ import (
)
const (
- sessionTimeLimitFlag = "session-time-limit"
-
- argusCustomEndpointFlag = "argus-custom-endpoint"
- authorizationCustomEndpointFlag = "authorization-custom-endpoint"
- dnsCustomEndpointFlag = "dns-custom-endpoint"
- loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint"
- logMeCustomEndpointFlag = "logme-custom-endpoint"
- mariaDBCustomEndpointFlag = "mariadb-custom-endpoint"
- mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint"
- objectStorageCustomEndpointFlag = "object-storage-custom-endpoint"
- openSearchCustomEndpointFlag = "opensearch-custom-endpoint"
- postgresFlexCustomEndpointFlag = "postgresflex-custom-endpoint"
- rabbitMQCustomEndpointFlag = "rabbitmq-custom-endpoint"
- redisCustomEndpointFlag = "redis-custom-endpoint"
- resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint"
- secretsManagerCustomEndpointFlag = "secrets-manager-custom-endpoint"
- serviceAccountCustomEndpointFlag = "service-account-custom-endpoint"
- skeCustomEndpointFlag = "ske-custom-endpoint"
- sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint"
+ sessionTimeLimitFlag = "session-time-limit"
+ identityProviderCustomWellKnownConfigurationFlag = "identity-provider-custom-well-known-configuration"
+ identityProviderCustomClientIdFlag = "identity-provider-custom-client-id"
+ allowedUrlDomainFlag = "allowed-url-domain"
+
+ 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"
+ mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint"
+ objectStorageCustomEndpointFlag = "object-storage-custom-endpoint"
+ observabilityCustomEndpointFlag = "observability-custom-endpoint"
+ openSearchCustomEndpointFlag = "opensearch-custom-endpoint"
+ postgresFlexCustomEndpointFlag = "postgresflex-custom-endpoint"
+ rabbitMQCustomEndpointFlag = "rabbitmq-custom-endpoint"
+ 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"
+ serviceAccountCustomEndpointFlag = "service-account-custom-endpoint"
+ serviceEnablementCustomEndpointFlag = "service-enablement-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"
+ sfsCustomEndpointFlag = "sfs-custom-endpoint"
+ cdnCustomEndpointFlag = "cdn-custom-endpoint"
)
type inputModel struct {
@@ -44,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",
@@ -67,13 +84,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
"$ stackit config set --dns-custom-endpoint https://dns.stackit.cloud"),
),
RunE: func(cmd *cobra.Command, args []string) error {
- model, err := parseInput(p, cmd)
+ 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)
}
@@ -122,11 +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(argusCustomEndpointFlag, "", "Argus API base URL, used in calls to this API")
+ 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")
@@ -138,16 +158,38 @@ 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")
+ cmd.Flags().String(serverOsUpdateCustomEndpointFlag, "", "Server Update Management API base URL, used in calls to this API")
+ cmd.Flags().String(runCommandCustomEndpointFlag, "", "Run Command API base URL, used in calls to this API")
cmd.Flags().String(skeCustomEndpointFlag, "", "SKE API base URL, used in calls to this API")
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)
+ err = viper.BindPFlag(config.IdentityProviderCustomWellKnownConfigurationKey, cmd.Flags().Lookup(identityProviderCustomWellKnownConfigurationFlag))
+ cobra.CheckErr(err)
+ err = viper.BindPFlag(config.IdentityProviderCustomClientIdKey, cmd.Flags().Lookup(identityProviderCustomClientIdFlag))
+ cobra.CheckErr(err)
+ err = viper.BindPFlag(config.AllowedUrlDomainKey, cmd.Flags().Lookup(allowedUrlDomainFlag))
+ cobra.CheckErr(err)
- err := viper.BindPFlag(config.ArgusCustomEndpointKey, cmd.Flags().Lookup(argusCustomEndpointFlag))
+ err = viper.BindPFlag(config.ObservabilityCustomEndpointKey, cmd.Flags().Lookup(observabilityCustomEndpointFlag))
cobra.CheckErr(err)
err = viper.BindPFlag(config.AuthorizationCustomEndpointKey, cmd.Flags().Lookup(authorizationCustomEndpointFlag))
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))
@@ -166,19 +208,41 @@ func configureFlags(cmd *cobra.Command) {
cobra.CheckErr(err)
err = viper.BindPFlag(config.RedisCustomEndpointKey, cmd.Flags().Lookup(redisCustomEndpointFlag))
cobra.CheckErr(err)
- err = viper.BindPFlag(config.ResourceManagerEndpointKey, cmd.Flags().Lookup(skeCustomEndpointFlag))
+ err = viper.BindPFlag(config.ResourceManagerEndpointKey, cmd.Flags().Lookup(resourceManagerCustomEndpointFlag))
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))
+ cobra.CheckErr(err)
+ err = viper.BindPFlag(config.RunCommandCustomEndpointKey, cmd.Flags().Lookup(runCommandCustomEndpointFlag))
+ cobra.CheckErr(err)
err = viper.BindPFlag(config.ServiceAccountCustomEndpointKey, cmd.Flags().Lookup(serviceAccountCustomEndpointFlag))
cobra.CheckErr(err)
+ err = viper.BindPFlag(config.ServiceEnablementCustomEndpointKey, cmd.Flags().Lookup(serviceEnablementCustomEndpointFlag))
+ cobra.CheckErr(err)
err = viper.BindPFlag(config.SKECustomEndpointKey, cmd.Flags().Lookup(skeCustomEndpointFlag))
cobra.CheckErr(err)
err = viper.BindPFlag(config.SQLServerFlexCustomEndpointKey, cmd.Flags().Lookup(sqlServerFlexCustomEndpointFlag))
cobra.CheckErr(err)
+ err = viper.BindPFlag(config.IaaSCustomEndpointKey, cmd.Flags().Lookup(iaasCustomEndpointFlag))
+ 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{
@@ -191,9 +255,12 @@ 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)
+ if allowedUrlDomainFromFlag != nil && allowedUrlDomainFlagValue == "" {
+ p.Warn("The allowed URL domain is set to empty. All URLs will be accepted regardless of their domain.\n")
}
model := inputModel{
@@ -201,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 b49d213da..bf63e4474 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"
@@ -18,56 +20,89 @@ const (
asyncFlag = globalflags.AsyncFlag
outputFormatFlag = globalflags.OutputFormatFlag
projectIdFlag = globalflags.ProjectIdFlag
+ regionFlag = globalflags.RegionFlag
verbosityFlag = globalflags.VerbosityFlag
- sessionTimeLimitFlag = "session-time-limit"
-
- argusCustomEndpointFlag = "argus-custom-endpoint"
- authorizationCustomEndpointFlag = "authorization-custom-endpoint"
- dnsCustomEndpointFlag = "dns-custom-endpoint"
- loadBalancerCustomEndpointFlag = "load-balancer-custom-endpoint"
- logMeCustomEndpointFlag = "logme-custom-endpoint"
- mariaDBCustomEndpointFlag = "mariadb-custom-endpoint"
- mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint"
- objectStorageCustomEndpointFlag = "object-storage-custom-endpoint"
- openSearchCustomEndpointFlag = "opensearch-custom-endpoint"
- postgresFlexCustomEndpointFlag = "postgresflex-custom-endpoint"
- rabbitMQCustomEndpointFlag = "rabbitmq-custom-endpoint"
- redisCustomEndpointFlag = "redis-custom-endpoint"
- resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint"
- secretsManagerCustomEndpointFlag = "secrets-manager-custom-endpoint"
- serviceAccountCustomEndpointFlag = "service-account-custom-endpoint"
- skeCustomEndpointFlag = "ske-custom-endpoint"
- sqlServerFlexCustomEndpointFlag = "sqlserverflex-custom-endpoint"
+ sessionTimeLimitFlag = "session-time-limit"
+ identityProviderCustomWellKnownConfigurationFlag = "identity-provider-custom-well-known-configuration"
+ identityProviderCustomClientIdFlag = "identity-provider-custom-client-id"
+ allowedUrlDomainFlag = "allowed-url-domain"
+
+ 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"
+ mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint"
+ objectStorageCustomEndpointFlag = "object-storage-custom-endpoint"
+ observabilityCustomEndpointFlag = "observability-custom-endpoint"
+ openSearchCustomEndpointFlag = "opensearch-custom-endpoint"
+ postgresFlexCustomEndpointFlag = "postgresflex-custom-endpoint"
+ rabbitMQCustomEndpointFlag = "rabbitmq-custom-endpoint"
+ 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 {
- Async bool
- OutputFormat bool
- ProjectId bool
- SessionTimeLimit bool
- Verbosity bool
-
- ArgusCustomEndpoint bool
- AuthorizationCustomEndpoint bool
- DNSCustomEndpoint bool
- LoadBalancerCustomEndpoint bool
- LogMeCustomEndpoint bool
- MariaDBCustomEndpoint bool
- MongoDBFlexCustomEndpoint bool
- ObjectStorageCustomEndpoint bool
- OpenSearchCustomEndpoint bool
- PostgresFlexCustomEndpoint bool
- RabbitMQCustomEndpoint bool
- RedisCustomEndpoint bool
- ResourceManagerCustomEndpoint bool
- SecretsManagerCustomEndpoint bool
- ServiceAccountCustomEndpoint bool
- SKECustomEndpoint bool
- SQLServerFlexCustomEndpoint bool
+ Async bool
+ OutputFormat bool
+ ProjectId bool
+ Region bool
+ Verbosity bool
+
+ SessionTimeLimit bool
+ IdentityProviderCustomEndpoint bool
+ IdentityProviderCustomClientID bool
+ AllowedUrlDomain bool
+
+ AuthorizationCustomEndpoint bool
+ DNSCustomEndpoint bool
+ EdgeCustomEndpoint bool
+ LoadBalancerCustomEndpoint bool
+ LogMeCustomEndpoint bool
+ MariaDBCustomEndpoint bool
+ MongoDBFlexCustomEndpoint bool
+ ObjectStorageCustomEndpoint bool
+ ObservabilityCustomEndpoint bool
+ OpenSearchCustomEndpoint bool
+ PostgresFlexCustomEndpoint bool
+ RabbitMQCustomEndpoint bool
+ 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",
@@ -84,8 +119,8 @@ func NewCmd(p *print.Printer) *cobra.Command {
`Unset the DNS custom endpoint stored in your configuration`,
"$ stackit config unset --dns-custom-endpoint"),
),
- RunE: func(cmd *cobra.Command, args []string) error {
- model := parseInput(p, cmd)
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ model := parseInput(params.Printer, cmd)
if model.Async {
viper.Set(config.AsyncKey, config.AsyncDefault)
@@ -96,15 +131,28 @@ func NewCmd(p *print.Printer) *cobra.Command {
if model.ProjectId {
viper.Set(config.ProjectIdKey, "")
}
- if model.SessionTimeLimit {
- viper.Set(config.SessionTimeLimitKey, config.SessionTimeLimitDefault)
+ if model.Region {
+ viper.Set(config.RegionKey, config.RegionDefault)
}
if model.Verbosity {
viper.Set(config.VerbosityKey, globalflags.VerbosityDefault)
}
- if model.ArgusCustomEndpoint {
- viper.Set(config.ArgusCustomEndpointKey, "")
+ if model.SessionTimeLimit {
+ viper.Set(config.SessionTimeLimitKey, config.SessionTimeLimitDefault)
+ }
+ if model.IdentityProviderCustomEndpoint {
+ viper.Set(config.IdentityProviderCustomWellKnownConfigurationKey, "")
+ }
+ if model.IdentityProviderCustomClientID {
+ viper.Set(config.IdentityProviderCustomClientIdKey, "")
+ }
+ if model.AllowedUrlDomain {
+ viper.Set(config.AllowedUrlDomainKey, config.AllowedUrlDomainDefault)
+ }
+
+ if model.ObservabilityCustomEndpoint {
+ viper.Set(config.ObservabilityCustomEndpointKey, "")
}
if model.AuthorizationCustomEndpoint {
viper.Set(config.AuthorizationCustomEndpointKey, "")
@@ -112,6 +160,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, "")
}
@@ -145,15 +196,48 @@ 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, "")
}
+ if model.ServiceEnablementCustomEndpoint {
+ viper.Set(config.ServiceEnablementCustomEndpointKey, "")
+ }
+ if model.ServerBackupCustomEndpoint {
+ viper.Set(config.ServerBackupCustomEndpointKey, "")
+ }
+ if model.ServerOsUpdateCustomEndpoint {
+ viper.Set(config.ServerOsUpdateCustomEndpointKey, "")
+ }
+ if model.RunCommandCustomEndpoint {
+ viper.Set(config.RunCommandCustomEndpointKey, "")
+ }
if model.SKECustomEndpoint {
viper.Set(config.SKECustomEndpointKey, "")
}
if model.SQLServerFlexCustomEndpoint {
viper.Set(config.SQLServerFlexCustomEndpointKey, "")
}
+ if model.IaaSCustomEndpoint {
+ viper.Set(config.IaaSCustomEndpointKey, "")
+ }
+ 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 {
@@ -169,13 +253,19 @@ func NewCmd(p *print.Printer) *cobra.Command {
func configureFlags(cmd *cobra.Command) {
cmd.Flags().Bool(asyncFlag, false, "Configuration option to run commands asynchronously")
cmd.Flags().Bool(projectIdFlag, false, "Project ID")
+ cmd.Flags().Bool(regionFlag, false, "Region")
cmd.Flags().Bool(outputFormatFlag, false, "Output format")
- cmd.Flags().Bool(sessionTimeLimitFlag, false, fmt.Sprintf("Maximum time before authentication is required again. If unset, defaults to %s", config.SessionTimeLimitDefault))
cmd.Flags().Bool(verbosityFlag, false, "Verbosity of the CLI")
- cmd.Flags().Bool(argusCustomEndpointFlag, false, "Argus API base URL. If unset, uses the default base URL")
+ 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")
+ cmd.Flags().Bool(identityProviderCustomClientIdFlag, false, "Identity Provider client ID, used for user authentication")
+ cmd.Flags().Bool(allowedUrlDomainFlag, false, fmt.Sprintf("Domain name, used for the verification of the URLs that are given in the IDP endpoint and curl commands. If unset, defaults to %s", config.AllowedUrlDomainDefault))
+
+ 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")
@@ -187,46 +277,66 @@ 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(serviceAccountCustomEndpointFlag, false, "SKE 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")
+ cmd.Flags().Bool(serverOsUpdateCustomEndpointFlag, false, "Server Update Management base URL. If unset, uses the default base URL")
+ cmd.Flags().Bool(runCommandCustomEndpointFlag, false, "Server Command base URL. If unset, uses the default base URL")
cmd.Flags().Bool(skeCustomEndpointFlag, false, "SKE API base URL. If unset, uses the default base URL")
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 {
model := inputModel{
- Async: flags.FlagToBoolValue(p, cmd, asyncFlag),
- OutputFormat: flags.FlagToBoolValue(p, cmd, outputFormatFlag),
- ProjectId: flags.FlagToBoolValue(p, cmd, projectIdFlag),
- SessionTimeLimit: flags.FlagToBoolValue(p, cmd, sessionTimeLimitFlag),
- Verbosity: flags.FlagToBoolValue(p, cmd, verbosityFlag),
-
- ArgusCustomEndpoint: flags.FlagToBoolValue(p, cmd, argusCustomEndpointFlag),
- AuthorizationCustomEndpoint: flags.FlagToBoolValue(p, cmd, authorizationCustomEndpointFlag),
- DNSCustomEndpoint: flags.FlagToBoolValue(p, cmd, dnsCustomEndpointFlag),
- LoadBalancerCustomEndpoint: flags.FlagToBoolValue(p, cmd, loadBalancerCustomEndpointFlag),
- LogMeCustomEndpoint: flags.FlagToBoolValue(p, cmd, logMeCustomEndpointFlag),
- MariaDBCustomEndpoint: flags.FlagToBoolValue(p, cmd, mariaDBCustomEndpointFlag),
- MongoDBFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, mongoDBFlexCustomEndpointFlag),
- ObjectStorageCustomEndpoint: flags.FlagToBoolValue(p, cmd, objectStorageCustomEndpointFlag),
- OpenSearchCustomEndpoint: flags.FlagToBoolValue(p, cmd, openSearchCustomEndpointFlag),
- PostgresFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, postgresFlexCustomEndpointFlag),
- RabbitMQCustomEndpoint: flags.FlagToBoolValue(p, cmd, rabbitMQCustomEndpointFlag),
- RedisCustomEndpoint: flags.FlagToBoolValue(p, cmd, redisCustomEndpointFlag),
- ResourceManagerCustomEndpoint: flags.FlagToBoolValue(p, cmd, resourceManagerCustomEndpointFlag),
- SecretsManagerCustomEndpoint: flags.FlagToBoolValue(p, cmd, secretsManagerCustomEndpointFlag),
- ServiceAccountCustomEndpoint: flags.FlagToBoolValue(p, cmd, serviceAccountCustomEndpointFlag),
- SKECustomEndpoint: flags.FlagToBoolValue(p, cmd, skeCustomEndpointFlag),
- SQLServerFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, sqlServerFlexCustomEndpointFlag),
- }
+ Async: flags.FlagToBoolValue(p, cmd, asyncFlag),
+ OutputFormat: flags.FlagToBoolValue(p, cmd, outputFormatFlag),
+ ProjectId: flags.FlagToBoolValue(p, cmd, projectIdFlag),
+ Region: flags.FlagToBoolValue(p, cmd, regionFlag),
+ Verbosity: flags.FlagToBoolValue(p, cmd, verbosityFlag),
+
+ SessionTimeLimit: flags.FlagToBoolValue(p, cmd, sessionTimeLimitFlag),
+ IdentityProviderCustomEndpoint: flags.FlagToBoolValue(p, cmd, identityProviderCustomWellKnownConfigurationFlag),
+ IdentityProviderCustomClientID: flags.FlagToBoolValue(p, cmd, identityProviderCustomClientIdFlag),
+ AllowedUrlDomain: flags.FlagToBoolValue(p, cmd, allowedUrlDomainFlag),
- if p.IsVerbosityDebug() {
- modelStr, err := print.BuildDebugStrFromInputModel(model)
- if err != nil {
- p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
- } else {
- p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
- }
+ 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),
+ MongoDBFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, mongoDBFlexCustomEndpointFlag),
+ ObjectStorageCustomEndpoint: flags.FlagToBoolValue(p, cmd, objectStorageCustomEndpointFlag),
+ ObservabilityCustomEndpoint: flags.FlagToBoolValue(p, cmd, observabilityCustomEndpointFlag),
+ OpenSearchCustomEndpoint: flags.FlagToBoolValue(p, cmd, openSearchCustomEndpointFlag),
+ PostgresFlexCustomEndpoint: flags.FlagToBoolValue(p, cmd, postgresFlexCustomEndpointFlag),
+ RabbitMQCustomEndpoint: flags.FlagToBoolValue(p, cmd, rabbitMQCustomEndpointFlag),
+ 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),
}
+ p.DebugInputModel(model)
return &model
}
diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go
index 92d760471..dda5dcaee 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"
@@ -11,27 +13,42 @@ import (
func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool {
flagValues := map[string]bool{
- asyncFlag: true,
- outputFormatFlag: true,
- projectIdFlag: true,
- sessionTimeLimitFlag: true,
- verbosityFlag: true,
+ asyncFlag: true,
+ outputFormatFlag: true,
+ projectIdFlag: true,
+ verbosityFlag: true,
+
+ sessionTimeLimitFlag: true,
+ identityProviderCustomWellKnownConfigurationFlag: true,
+ identityProviderCustomClientIdFlag: true,
+ allowedUrlDomainFlag: true,
- argusCustomEndpointFlag: true,
authorizationCustomEndpointFlag: true,
dnsCustomEndpointFlag: true,
+ edgeCustomEndpointFlag: true,
loadBalancerCustomEndpointFlag: true,
logMeCustomEndpointFlag: true,
mariaDBCustomEndpointFlag: true,
objectStorageCustomEndpointFlag: true,
+ observabilityCustomEndpointFlag: true,
openSearchCustomEndpointFlag: true,
rabbitMQCustomEndpointFlag: true,
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)
@@ -41,27 +58,42 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool
func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
- Async: true,
- OutputFormat: true,
- ProjectId: true,
- SessionTimeLimit: true,
- Verbosity: true,
+ Async: true,
+ OutputFormat: true,
+ ProjectId: true,
+ Verbosity: true,
+
+ SessionTimeLimit: true,
+ IdentityProviderCustomEndpoint: true,
+ IdentityProviderCustomClientID: true,
+ AllowedUrlDomain: true,
- ArgusCustomEndpoint: true,
AuthorizationCustomEndpoint: true,
DNSCustomEndpoint: true,
+ EdgeCustomEndpoint: true,
LoadBalancerCustomEndpoint: true,
LogMeCustomEndpoint: true,
MariaDBCustomEndpoint: true,
ObjectStorageCustomEndpoint: true,
+ ObservabilityCustomEndpoint: true,
OpenSearchCustomEndpoint: true,
RabbitMQCustomEndpoint: true,
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)
@@ -90,24 +122,39 @@ func TestParseInput(t *testing.T) {
model.Async = false
model.OutputFormat = false
model.ProjectId = false
- model.SessionTimeLimit = false
model.Verbosity = false
- model.ArgusCustomEndpoint = false
+ model.SessionTimeLimit = false
+ model.IdentityProviderCustomEndpoint = false
+ model.IdentityProviderCustomClientID = false
+ model.AllowedUrlDomain = false
+
model.AuthorizationCustomEndpoint = false
model.DNSCustomEndpoint = false
+ model.EdgeCustomEndpoint = false
model.LoadBalancerCustomEndpoint = false
model.LogMeCustomEndpoint = false
model.MariaDBCustomEndpoint = false
model.ObjectStorageCustomEndpoint = false
+ model.ObservabilityCustomEndpoint = false
model.OpenSearchCustomEndpoint = false
model.RabbitMQCustomEndpoint = false
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
}),
},
{
@@ -131,13 +178,43 @@ func TestParseInput(t *testing.T) {
}),
},
{
- description: "argus custom endpoint empty",
+ description: "identity provider custom endpoint empty",
flagValues: fixtureFlagValues(func(flagValues map[string]bool) {
- flagValues[argusCustomEndpointFlag] = false
+ flagValues[identityProviderCustomWellKnownConfigurationFlag] = false
}),
isValid: true,
expectedModel: fixtureInputModel(func(model *inputModel) {
- model.ArgusCustomEndpoint = false
+ model.IdentityProviderCustomEndpoint = false
+ }),
+ },
+ {
+ description: "identity provider custom client id empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]bool) {
+ flagValues[identityProviderCustomClientIdFlag] = false
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.IdentityProviderCustomClientID = false
+ }),
+ },
+ {
+ description: "allowed url domain empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]bool) {
+ flagValues[allowedUrlDomainFlag] = false
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.AllowedUrlDomain = false
+ }),
+ },
+ {
+ description: "observability custom endpoint empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]bool) {
+ flagValues[observabilityCustomEndpointFlag] = false
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.ObservabilityCustomEndpoint = false
}),
},
{
@@ -150,6 +227,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) {
@@ -160,6 +247,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) {
@@ -170,6 +267,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) {
@@ -190,11 +297,71 @@ func TestParseInput(t *testing.T) {
model.ResourceManagerCustomEndpoint = false
}),
},
+ {
+ description: "serverbackup custom endpoint empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]bool) {
+ flagValues[serverBackupCustomEndpointFlag] = false
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.ServerBackupCustomEndpoint = false
+ }),
+ },
+ {
+ description: "serverosupdate custom endpoint empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]bool) {
+ flagValues[serverOsUpdateCustomEndpointFlag] = false
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.ServerOsUpdateCustomEndpoint = false
+ }),
+ },
+ {
+ description: "runcommand custom endpoint empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]bool) {
+ flagValues[runCommandCustomEndpointFlag] = false
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.RunCommandCustomEndpoint = false
+ }),
+ },
+ {
+ description: "token custom endpoint empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]bool) {
+ flagValues[tokenCustomEndpointFlag] = false
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ 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 dcc7529f3..341654dfd 100644
--- a/internal/cmd/curl/curl.go
+++ b/internal/cmd/curl/curl.go
@@ -2,21 +2,24 @@ package curl
import (
"bytes"
+ "encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
- "net/url"
"os"
"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"
"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/utils"
"github.com/spf13/cobra"
)
@@ -44,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",
@@ -56,7 +59,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
examples.NewExample(
`Get all the DNS zones for project with ID xxx via GET request to https://dns.api.stackit.cloud/v1/projects/xxx/zones, write complete response (headers and body) to file "./output.txt"`,
- "$ stackit curl https://dns.api.stackit.cloud/v1/projects/xxx/zones -include --output ./output.txt",
+ "$ stackit curl https://dns.api.stackit.cloud/v1/projects/xxx/zones --include --output ./output.txt",
),
examples.NewExample(
`Create a new DNS zone for project with ID xxx via POST request to https://dns.api.stackit.cloud/v1/projects/xxx/zones with payload from file "./payload.json"`,
@@ -67,14 +70,14 @@ func NewCmd(p *print.Printer) *cobra.Command {
`$ stackit curl https://dns.api.stackit.cloud/v1/projects/xxx/zones -X POST -H "Authorization: Bearer yyy" --fail`,
),
),
- Args: args.SingleArg(urlArg, validateURL),
+ 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
}
@@ -98,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
}
@@ -113,21 +116,6 @@ func NewCmd(p *print.Printer) *cobra.Command {
return cmd
}
-func validateURL(value string) error {
- urlStruct, err := url.Parse(value)
- if err != nil {
- return fmt.Errorf("parse URL: %w", err)
- }
- urlHost := urlStruct.Hostname()
- if urlHost == "" {
- return fmt.Errorf("bad url")
- }
- if !strings.HasSuffix(urlHost, "stackit.cloud") {
- return fmt.Errorf("only urls belonging to STACKIT are permitted, hostname must end in stackit.cloud")
- }
- return nil
-}
-
func configureFlags(cmd *cobra.Command) {
requestMethodOptions := []string{
http.MethodGet,
@@ -167,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
}
@@ -185,11 +165,22 @@ func getBearerToken(p *print.Printer) (string, error) {
p.Debug(print.ErrorLevel, "configure authentication: %v", err)
return "", &errors.AuthError{}
}
- token, err := auth.GetAuthField(auth.ACCESS_TOKEN)
+
+ userSessionExpired, err := auth.UserSessionExpired()
+ if err != nil {
+ return "", err
+ }
+ if userSessionExpired {
+ return "", &errors.SessionExpiredError{}
+ }
+
+ accessToken, err := auth.GetValidAccessToken(p)
if err != nil {
- return "", fmt.Errorf("get access token: %w", err)
+ p.Debug(print.ErrorLevel, "get valid access token: %v", err)
+ return "", &errors.SessionExpiredError{}
}
- return token, nil
+
+ return accessToken, nil
}
func buildRequest(model *inputModel, bearerToken string) (*http.Request, error) {
@@ -213,6 +204,9 @@ func buildRequest(model *inputModel, bearerToken string) (*http.Request, error)
}
func outputResponse(p *print.Printer, model *inputModel, resp *http.Response) error {
+ if resp == nil {
+ return fmt.Errorf("http response is empty")
+ }
output := make([]byte, 0)
if model.IncludeResponseHeaders {
respHeader, err := httputil.DumpResponse(resp, false)
@@ -225,12 +219,28 @@ func outputResponse(p *print.Printer, model *inputModel, resp *http.Response) er
if err != nil {
return fmt.Errorf("read response body: %w", err)
}
+
+ if strings.Contains(strings.ToLower(string(respBody)), "jwt is expired") {
+ return &errors.SessionExpiredError{}
+ }
+
+ if strings.Contains(strings.ToLower(string(respBody)), "jwt is missing") {
+ return &errors.AuthError{}
+ }
+
+ var prettyJSON bytes.Buffer
+ if json.Valid(respBody) {
+ if err := json.Indent(&prettyJSON, respBody, "", " "); err == nil {
+ respBody = prettyJSON.Bytes()
+ } // if indenting fails, fall back to original body
+ }
+
output = append(output, respBody...)
if model.OutputFile == nil {
p.Outputln(string(output))
} else {
- err = os.WriteFile(*model.OutputFile, output, 0o600)
+ err = os.WriteFile(utils.PtrString(model.OutputFile), output, 0o600)
if err != nil {
return fmt.Errorf("write output to file: %w", err)
}
diff --git a/internal/cmd/curl/curl_test.go b/internal/cmd/curl/curl_test.go
index b6e9faa30..ce2cd3c37 100644
--- a/internal/cmd/curl/curl_test.go
+++ b/internal/cmd/curl/curl_test.go
@@ -4,15 +4,21 @@ import (
"bytes"
"context"
"fmt"
+ "io"
"net/http"
+ "os"
+ "strings"
"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/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"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
)
var testURL = "https://some-service.api.stackit.cloud/v1/foo?bar=baz"
@@ -35,7 +41,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st
dataFlag: "data",
includeResponseHeadersFlag: "true",
failOnHTTPErrorFlag: "true",
- outputFileFlag: "path/to/output.txt",
+ outputFileFlag: "./output.txt",
}
for _, mod := range mods {
mod(flagValues)
@@ -51,7 +57,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
Data: utils.Ptr("data"),
IncludeResponseHeaders: true,
FailOnHTTPError: true,
- OutputFile: utils.Ptr("path/to/output.txt"),
+ OutputFile: utils.Ptr("./output.txt"),
}
for _, mod := range mods {
mod(model)
@@ -78,6 +84,7 @@ func TestParseInput(t *testing.T) {
argValues []string
flagValues map[string]string
headerFlagValues []string
+ allowedURLDomain string
isValid bool
expectedModel *inputModel
}{
@@ -123,10 +130,14 @@ func TestParseInput(t *testing.T) {
{
description: "URL outside STACKIT",
argValues: []string{
- "https://www.very-suspicious-website.com/",
+ "https://www.example.website.com/",
},
- flagValues: fixtureFlagValues(),
- isValid: false,
+ flagValues: fixtureFlagValues(),
+ allowedURLDomain: "",
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.URL = "https://www.example.website.com/"
+ }),
},
{
description: "invalid method 1",
@@ -207,12 +218,15 @@ 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)
}
+ viper.Reset()
+ viper.Set(config.AllowedUrlDomainKey, tt.allowedURLDomain)
+
for flag, value := range tt.flagValues {
err := cmd.Flags().Set(flag, value)
if err != nil {
@@ -393,3 +407,61 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResponse(t *testing.T) {
+ type args struct {
+ model *inputModel
+ resp *http.Response
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "http response as argument",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &http.Response{Body: http.NoBody},
+ },
+ wantErr: false,
+ },
+ {
+ name: "expired jwt curl",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &http.Response{Body: io.NopCloser(strings.NewReader("Jwt is expired"))},
+ },
+ wantErr: true,
+ },
+ {
+ name: "mssing jwt curl",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &http.Response{Body: io.NopCloser(strings.NewReader("Jwt is missing"))},
+ },
+ 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 := outputResponse(p, tt.args.model, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResponse() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if tt.args.model != nil {
+ if _, err := os.Stat(*tt.args.model.OutputFile); err == nil {
+ if err := os.Remove(*tt.args.model.OutputFile); err != nil {
+ t.Errorf("remove output file error = %v", err)
+ }
+ }
+ }
+ })
+ }
+}
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 a6beefdc3..2687b34da 100644
--- a/internal/cmd/dns/record-set/create/create.go
+++ b/internal/cmd/dns/record-set/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/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"
@@ -16,8 +17,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/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
"github.com/stackitcloud/stackit-sdk-go/services/dns/wait"
)
@@ -30,7 +29,8 @@ const (
ttlFlag = "ttl"
typeFlag = "type"
- defaultType = "A"
+ defaultType = dns.CREATERECORDSETPAYLOADTYPE_A
+ txtType = dns.CREATERECORDSETPAYLOADTYPE_TXT
)
type inputModel struct {
@@ -40,10 +40,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",
@@ -56,29 +56,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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,7 +89,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating record set")
_, err = wait.CreateRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, recordSetId).WaitWithContext(ctx)
if err != nil {
@@ -100,7 +98,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
s.Stop()
}
- return outputResult(p, model, zoneLabel, resp)
+ return outputResult(params.Printer, model, zoneLabel, resp)
},
}
configureFlags(cmd)
@@ -108,25 +106,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,18 +137,24 @@ 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 p.IsVerbosityDebug() {
- modelStr, err := print.BuildDebugStrFromInputModel(model)
- if 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.Type == txtType {
+ for idx := range model.Records {
+ // Based on RFC 1035 section 2.3.4, TXT Records are limited to 255 Characters
+ // Longer strings need to be split into multiple records
+ if len(model.Records[idx]) > 255 {
+ var err error
+ model.Records[idx], err = dnsUtils.FormatTxtRecord(model.Records[idx])
+ if err != nil {
+ return nil, err
+ }
+ }
}
}
+ p.DebugInputModel(model)
return &model, nil
}
@@ -167,29 +176,15 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClie
}
func outputResult(p *print.Printer, model *inputModel, zoneLabel string, resp *dns.RecordSetResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal DNS record-set: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+ if resp == nil {
+ return fmt.Errorf("record set response is empty")
+ }
+ 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, *resp.Rrset.Id)
+ 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 79044ecde..633d7ec60 100644
--- a/internal/cmd/dns/record-set/create/create_test.go
+++ b/internal/cmd/dns/record-set/create/create_test.go
@@ -2,20 +2,22 @@ package create
import (
"context"
+ "fmt"
+ "strings"
"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-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,15 +25,21 @@ var testClient = &dns.APIClient{}
var testProjectId = uuid.NewString()
var testZoneId = uuid.NewString()
+var recordTxtOver255Char = []string{
+ "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoo",
+ "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoo",
+ "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobar",
+}
+
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)
@@ -67,7 +75,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)
@@ -76,8 +84,9 @@ func fixtureRequest(mods ...func(request *dns.ApiCreateRecordSetRequest)) dns.Ap
}
func TestParseInput(t *testing.T) {
- tests := []struct {
+ var tests = []struct {
description string
+ argValues []string
flagValues map[string]string
recordFlagValues []string
isValid bool
@@ -97,10 +106,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{
@@ -117,12 +126,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{
@@ -141,21 +150,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,
},
@@ -236,60 +245,32 @@ func TestParseInput(t *testing.T) {
model.Records = append(model.Records, "1.2.3.4", "5.6.7.8")
}),
},
- }
-
- for _, tt := range tests {
- t.Run(tt.description, func(t *testing.T) {
- 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
+ {
+ description: "TXT record with > 255 characters",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[typeFlag] = string(txtType)
+ flagValues[recordFlag] = strings.Join(recordTxtOver255Char, "")
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ var content string
+ for idx, val := range recordTxtOver255Char {
+ content += fmt.Sprintf("%q", val)
+ if idx != len(recordTxtOver255Char)-1 {
+ content += " "
}
- 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)
- }
+ model.Records = []string{content}
+ model.Type = txtType
+ }),
+ },
+ }
+ 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{
+ recordFlag: tt.recordFlagValues,
+ }, tt.isValid)
})
}
}
@@ -342,3 +323,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ zoneLabel string
+ resp *dns.RecordSetResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only record set as argument",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &dns.RecordSetResponse{Rrset: &dns.RecordSet{}},
+ },
+ 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.zoneLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/dns/record-set/delete/delete.go b/internal/cmd/dns/record-set/delete/delete.go
index 4bb3a1c88..09337b89e 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,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting record set")
_, err = wait.DeleteRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId).WaitWithContext(ctx)
if err != nil {
@@ -101,7 +101,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 221729d91..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
}
@@ -114,24 +106,11 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClie
}
func outputResult(p *print.Printer, outputFormat string, recordSet *dns.RecordSet) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal DNS record set: %w", err)
- }
- p.Outputln(string(details))
+ if recordSet == nil {
+ return fmt.Errorf("record set response is empty")
+ }
- 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)
@@ -139,15 +118,15 @@ func outputResult(p *print.Printer, outputFormat string, recordSet *dns.RecordSe
recordsDataJoin := strings.Join(recordsData, ", ")
table := tables.NewTable()
- table.AddRow("ID", *recordSet.Id)
+ table.AddRow("ID", utils.PtrString(recordSet.Id))
table.AddSeparator()
- table.AddRow("NAME", *recordSet.Name)
+ table.AddRow("NAME", utils.PtrString(recordSet.Name))
table.AddSeparator()
- table.AddRow("STATE", *recordSet.State)
+ table.AddRow("STATE", utils.PtrString(recordSet.State))
table.AddSeparator()
- table.AddRow("TTL", *recordSet.Ttl)
+ table.AddRow("TTL", utils.PtrString(recordSet.Ttl))
table.AddSeparator()
- table.AddRow("TYPE", *recordSet.Type)
+ table.AddRow("TYPE", utils.PtrString(recordSet.Type))
table.AddSeparator()
table.AddRow("RECORDS DATA", recordsDataJoin)
err := table.Display(p)
@@ -156,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 1e33b5c94..8f7214918 100644
--- a/internal/cmd/dns/record-set/describe/describe_test.go
+++ b/internal/cmd/dns/record-set/describe/describe_test.go
@@ -4,17 +4,17 @@ 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/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/testutils"
"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 +35,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 +104,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 +112,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 +120,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 +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)
})
}
}
@@ -245,3 +198,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ recordSet *dns.RecordSet
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only record set as argument",
+ args: args{
+ recordSet: &dns.RecordSet{Records: &[]dns.Record{}},
+ },
+ 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.recordSet); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/dns/record-set/list/list.go b/internal/cmd/dns/record-set/list/list.go
index 40edbcbec..88a7f5324 100644
--- a/internal/cmd/dns/record-set/list/list.go
+++ b/internal/cmd/dns/record-set/list/list.go
@@ -2,11 +2,13 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -16,8 +18,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/client"
dnsUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
)
@@ -31,6 +32,7 @@ const (
limitFlag = "limit"
pageSizeFlag = "page-size"
+ defaultPage = 1
pageSizeDefault = 100
deleteSucceededState = "DELETE_SUCCEEDED"
)
@@ -48,7 +50,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,13 +75,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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
}
@@ -89,16 +91,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)
},
}
@@ -122,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{}
@@ -162,15 +162,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
}
@@ -193,8 +185,20 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient dnsClient, p
if model.OrderByName != nil {
req = req.OrderByName(strings.ToUpper(*model.OrderByName))
}
- req = req.PageSize(int32(model.PageSize))
- req = req.Page(int32(page))
+
+ // check integer overflows
+ if model.PageSize > math.MaxInt32 || model.PageSize < math.MinInt32 {
+ req = req.PageSize(pageSizeDefault)
+ } else {
+ req = req.PageSize(int32(model.PageSize))
+ }
+
+ if page > math.MaxInt32 || page < math.MinInt32 {
+ req = req.Page(defaultPage)
+ } else {
+ req = req.Page(int32(page))
+ }
+
return req
}
@@ -234,25 +238,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))
- 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 {
@@ -262,7 +254,14 @@ func outputResult(p *print.Printer, outputFormat string, recordSets []dns.Record
recordData = append(recordData, *r.Content)
}
recordDataJoin := strings.Join(recordData, ", ")
- table.AddRow(*rs.Id, *rs.Name, *rs.State, *rs.Ttl, *rs.Type, recordDataJoin)
+ table.AddRow(
+ utils.PtrString(rs.Id),
+ utils.PtrString(rs.Name),
+ utils.PtrString(rs.State),
+ utils.PtrString(rs.Ttl),
+ utils.PtrString(rs.Type),
+ recordDataJoin,
+ )
}
err := table.Display(p)
if err != nil {
@@ -270,5 +269,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 9c571c3ec..af2dc852b 100644
--- a/internal/cmd/dns/record-set/list/list_test.go
+++ b/internal/cmd/dns/record-set/list/list_test.go
@@ -8,19 +8,19 @@ import (
"strconv"
"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-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")
@@ -30,10 +30,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)
@@ -72,6 +72,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
@@ -128,8 +129,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{
@@ -144,21 +145,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,
},
@@ -214,46 +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)
- err := globalflags.Configure(cmd.Flags())
- if err != nil {
- t.Fatalf("configure global flags: %v", err)
- }
-
- for flag, value := range tt.flagValues {
- err := 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)
})
}
}
@@ -489,7 +451,7 @@ func TestFetchRecordSets(t *testing.T) {
}
return
}
- if err == nil && tt.apiCallFails {
+ if tt.apiCallFails {
t.Fatalf("did not fail on invalid input")
}
if numAPICalls != tt.expectedNumAPICalls {
@@ -501,3 +463,31 @@ func TestFetchRecordSets(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ zoneLabel string
+ recordSets []dns.RecordSet
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.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 77a054a14..b9fb0e942 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"
@@ -28,6 +30,7 @@ const (
nameFlag = "name"
recordFlag = "record"
ttlFlag = "ttl"
+ txtType = "TXT"
)
type inputModel struct {
@@ -38,9 +41,10 @@ type inputModel struct {
Name *string
Records *[]string
TTL *int64
+ 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",
@@ -53,37 +57,48 @@ 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 update record set %s of zone %s?", recordSetLabel, zoneLabel)
- err = p.PromptForConfirmation(prompt)
+ typeLabel, err := dnsUtils.GetRecordSetType(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get record set type: %v", err)
+ }
+ model.Type = typeLabel
+
+ if utils.PtrString(model.Type) == txtType {
+ err = parseTxtRecord(model.Records)
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
req := buildRequest(ctx, model, apiClient)
_, err = req.Execute()
@@ -93,7 +108,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Updating record set")
_, err = wait.PartialUpdateRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId).WaitWithContext(ctx)
if err != nil {
@@ -106,7 +121,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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
},
}
@@ -153,16 +168,29 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
TTL: ttl,
}
- if p.IsVerbosityDebug() {
- modelStr, err := print.BuildDebugStrFromInputModel(model)
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func parseTxtRecord(records *[]string) error {
+ if records == nil {
+ return nil
+ }
+ if len(*records) == 0 {
+ return nil
+ }
+
+ for idx := range *records {
+ var err error
+ // Based on RFC 1035 section 2.3.4, TXT Records are limited to 255 Characters.
+ // Longer strings need to be split into multiple records
+ (*records)[idx], err = dnsUtils.FormatTxtRecord((*records)[idx])
if 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 err
}
}
- return &model, nil
+ return nil
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiPartialUpdateRecordSetRequest {
diff --git a/internal/cmd/dns/record-set/update/update_test.go b/internal/cmd/dns/record-set/update/update_test.go
index 19e544a76..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")
@@ -24,6 +24,13 @@ var testProjectId = uuid.NewString()
var testZoneId = uuid.NewString()
var testRecordSetId = uuid.NewString()
+var (
+ text255Characters = "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoo"
+ text256Characters = "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoob"
+ result256Characters = "\"foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoo\" \"b\""
+ text4050Characters = "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoo"
+)
+
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testRecordSetId,
@@ -36,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)
@@ -78,10 +85,11 @@ func fixtureRequest(mods ...func(request *dns.ApiPartialUpdateRecordSetRequest))
},
Ttl: utils.Ptr(int64(3600)),
})
+ req := &request
for _, mod := range mods {
- mod(&request)
+ mod(req)
}
- return request
+ return *req
}
func TestParseInput(t *testing.T) {
@@ -122,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{
@@ -139,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{
@@ -164,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,
},
@@ -172,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,
},
@@ -180,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,
},
@@ -245,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)
@@ -306,6 +314,71 @@ func TestParseInput(t *testing.T) {
}
}
+func TestParseTxtRecord(t *testing.T) {
+ tests := []struct {
+ description string
+ records *[]string
+ expectedResult *[]string
+ isValid bool
+ shouldErr bool
+ }{
+ {
+ description: "empty",
+ records: nil,
+ expectedResult: nil,
+ isValid: true,
+ },
+ {
+ description: "base",
+ records: &[]string{"foobar"},
+ expectedResult: &[]string{"foobar"},
+ isValid: true,
+ },
+ {
+ description: "input has length of 255 characters and should not split",
+ records: &[]string{text255Characters},
+ expectedResult: &[]string{text255Characters},
+ isValid: true,
+ },
+ {
+ description: "input has length 256 characters and should split",
+ records: &[]string{text256Characters},
+ expectedResult: &[]string{result256Characters},
+ isValid: true,
+ },
+ {
+ description: "input has length 4050 characters and should fail",
+ records: &[]string{text4050Characters},
+ isValid: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ err := parseTxtRecord(tt.records)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("should not fail but got error: %v", err)
+ return
+ }
+ if err == nil && !tt.isValid {
+ t.Fatalf("should fail but got none")
+ return
+ }
+
+ if !tt.isValid {
+ t.Fatalf("should fail but got none")
+ return
+ }
+ diff := cmp.Diff(tt.expectedResult, tt.records)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
diff --git a/internal/cmd/dns/zone/clone/clone.go b/internal/cmd/dns/zone/clone/clone.go
new file mode 100644
index 000000000..6bc02546d
--- /dev/null
+++ b/internal/cmd/dns/zone/clone/clone.go
@@ -0,0 +1,164 @@
+package clone
+
+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/services/dns/client"
+ 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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/dns"
+ "github.com/stackitcloud/stackit-sdk-go/services/dns/wait"
+)
+
+const (
+ nameFlag = "name"
+ dnsNameFlag = "dns-name"
+ descriptionFlag = "description"
+ adjustRecordsFlag = "adjust-records"
+ zoneIdArg = "ZONE_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Name *string
+ DnsName *string
+ Description *string
+ AdjustRecords *bool
+ ZoneId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("clone %s", zoneIdArg),
+ Short: "Clones a DNS zone",
+ Long: "Clones an existing DNS zone with all record sets to a new zone with a different name.",
+ Args: args.SingleArg(zoneIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com"`,
+ "$ stackit dns zone clone xxx --dns-name www.my-zone.com"),
+ examples.NewExample(
+ `Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" and display name "new-zone"`,
+ "$ stackit dns zone clone xxx --dns-name www.my-zone.com --name new-zone"),
+ examples.NewExample(
+ `Clones a DNS zone with ID "xxx" to a new zone with DNS name "www.my-zone.com" and adjust records "true"`,
+ "$ stackit dns zone clone xxx --dns-name www.my-zone.com --adjust-records"),
+ ),
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("clone DNS zone: %w", err)
+ }
+ zoneId := *resp.Zone.Id
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Cloning zone")
+ _, err = wait.CreateZoneWaitHandler(ctx, apiClient, model.ProjectId, zoneId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for DNS zone cloning: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model, zoneLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(nameFlag, "", "User given new name for the cloned zone")
+ cmd.Flags().String(dnsNameFlag, "", "Fully qualified domain name of the new DNS zone to clone")
+ cmd.Flags().String(descriptionFlag, "", "New description for the cloned zone")
+ cmd.Flags().Bool(adjustRecordsFlag, false, "Sets content and replaces the DNS name of the original zone with the new DNS name of the cloned zone")
+
+ err := flags.MarkFlagsRequired(cmd, dnsNameFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ zoneId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ DnsName: flags.FlagToStringPointer(p, cmd, dnsNameFlag),
+ Description: flags.FlagToStringPointer(p, cmd, descriptionFlag),
+ AdjustRecords: flags.FlagToBoolPointer(p, cmd, adjustRecordsFlag),
+ ZoneId: zoneId,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiCloneZoneRequest {
+ req := apiClient.CloneZone(ctx, model.ProjectId, model.ZoneId)
+ req = req.CloneZonePayload(dns.CloneZonePayload{
+ Name: model.Name,
+ DnsName: model.DnsName,
+ Description: model.Description,
+ AdjustRecords: model.AdjustRecords,
+ })
+ return req
+}
+
+func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *dns.ZoneResponse) error {
+ if resp == nil {
+ return fmt.Errorf("dns zone response is empty")
+ }
+ 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
new file mode 100644
index 000000000..4479eea6e
--- /dev/null
+++ b/internal/cmd/dns/zone/clone/clone_test.go
@@ -0,0 +1,256 @@
+package clone
+
+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-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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &dns.APIClient{}
+var testProjectId = uuid.NewString()
+var testZoneId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testZoneId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ nameFlag: "example",
+ dnsNameFlag: "example.com",
+ descriptionFlag: "Example",
+ adjustRecordsFlag: "false",
+ }
+ 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: utils.Ptr("example"),
+ DnsName: utils.Ptr("example.com"),
+ Description: utils.Ptr("Example"),
+ AdjustRecords: utils.Ptr(false),
+ ZoneId: testZoneId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *dns.ApiCloneZoneRequest)) dns.ApiCloneZoneRequest {
+ request := testClient.CloneZone(testCtx, testProjectId, testZoneId)
+ request = request.CloneZonePayload(dns.CloneZonePayload{
+ Name: utils.Ptr("example"),
+ DnsName: utils.Ptr("example.com"),
+ Description: utils.Ptr("Example"),
+ AdjustRecords: utils.Ptr(false),
+ })
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "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 fields only",
+ argValues: []string{testZoneId},
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ dnsNameFlag: "example.com",
+ },
+ isValid: true,
+ expectedModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ DnsName: utils.Ptr("example.com"),
+ ZoneId: testZoneId,
+ },
+ },
+ {
+ 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: "zone id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "zone id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid)
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ isValid bool
+ expectedRequest dns.ApiCloneZoneRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "required fields only",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ DnsName: utils.Ptr("example.com"),
+ ZoneId: testZoneId,
+ },
+ expectedRequest: testClient.CloneZone(testCtx, testProjectId, testZoneId).
+ CloneZonePayload(dns.CloneZonePayload{
+ DnsName: utils.Ptr("example.com"),
+ }),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ projectLabel string
+ resp *dns.ZoneResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only zone response as argument",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &dns.ZoneResponse{Zone: &dns.Zone{}},
+ },
+ 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/dns/zone/create/create.go b/internal/cmd/dns/zone/create/create.go
index 95507e40d..75ef0a3e5 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"
@@ -15,6 +15,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/client"
"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/dns"
@@ -44,7 +45,7 @@ type inputModel struct {
DefaultTTL *int64
Primaries *[]string
Acl *string
- Type *string
+ Type *dns.CreateZonePayloadTypes
RetryTime *int64
RefreshTime *int64
NegativeCache *int64
@@ -54,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",
@@ -70,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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
@@ -105,7 +104,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating zone")
_, err = wait.CreateZoneWaitHandler(ctx, apiClient, model.ProjectId, zoneId).WaitWithContext(ctx)
if err != nil {
@@ -114,7 +113,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
s.Stop()
}
- return outputResult(p, model, projectLabel, resp)
+ return outputResult(params.Printer, model, projectLabel, resp)
},
}
configureFlags(cmd)
@@ -122,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")
@@ -140,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),
@@ -153,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),
@@ -163,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
}
@@ -196,29 +197,15 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClie
}
func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *dns.ZoneResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal DNS zone: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+ if resp == nil {
+ return fmt.Errorf("dns zone response is empty")
+ }
+ 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, *resp.Zone.Id)
+ 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 8f8991c7e..bf26e688a 100644
--- a/internal/cmd/dns/zone/create/create_test.go
+++ b/internal/cmd/dns/zone/create/create_test.go
@@ -4,18 +4,18 @@ 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-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")
@@ -24,20 +24,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)
@@ -56,7 +56,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)),
@@ -79,7 +79,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)),
@@ -97,6 +97,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
@@ -116,9 +117,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{
@@ -133,20 +134,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{
@@ -157,9 +157,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)),
@@ -172,21 +172,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,
},
@@ -216,56 +216,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)
})
}
}
@@ -313,3 +266,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ projectLabel string
+ resp *dns.ZoneResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only zone response as argument",
+ args: args{
+ model: fixtureInputModel(),
+ resp: &dns.ZoneResponse{Zone: &dns.Zone{}},
+ },
+ 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/dns/zone/delete/delete.go b/internal/cmd/dns/zone/delete/delete.go
index d5309d65e..a204a5dbe 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 %s? (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,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting zone")
_, err = wait.DeleteZoneWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId).WaitWithContext(ctx)
if err != nil {
@@ -90,7 +90,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 578dee03c..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
}
@@ -99,59 +91,46 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClie
}
func outputResult(p *print.Printer, outputFormat string, zone *dns.Zone) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal DNS zone: %w", err)
- }
- p.Outputln(string(details))
+ if zone == nil {
+ return fmt.Errorf("zone response is empty")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, zone, func() error {
table := tables.NewTable()
- table.AddRow("ID", *zone.Id)
+ table.AddRow("ID", utils.PtrString(zone.Id))
table.AddSeparator()
- table.AddRow("NAME", *zone.Name)
+ table.AddRow("NAME", utils.PtrString(zone.Name))
table.AddSeparator()
- table.AddRow("DESCRIPTION", *zone.Description)
+ table.AddRow("DESCRIPTION", utils.PtrString(zone.Description))
table.AddSeparator()
- table.AddRow("STATE", *zone.State)
+ table.AddRow("STATE", utils.PtrString(zone.State))
table.AddSeparator()
- table.AddRow("TYPE", *zone.Type)
+ table.AddRow("TYPE", utils.PtrString(zone.Type))
table.AddSeparator()
- table.AddRow("DNS NAME", *zone.DnsName)
+ table.AddRow("DNS NAME", utils.PtrString(zone.DnsName))
table.AddSeparator()
- table.AddRow("REVERSE ZONE", *zone.IsReverseZone)
+ table.AddRow("REVERSE ZONE", utils.PtrString(zone.IsReverseZone))
table.AddSeparator()
- table.AddRow("RECORD COUNT", *zone.RecordCount)
+ table.AddRow("RECORD COUNT", utils.PtrString(zone.RecordCount))
table.AddSeparator()
- table.AddRow("CONTACT EMAIL", *zone.ContactEmail)
+ table.AddRow("CONTACT EMAIL", utils.PtrString(zone.ContactEmail))
table.AddSeparator()
- table.AddRow("DEFAULT TTL", *zone.DefaultTTL)
+ table.AddRow("DEFAULT TTL", utils.PtrString(zone.DefaultTTL))
table.AddSeparator()
- table.AddRow("SERIAL NUMBER", *zone.SerialNumber)
+ table.AddRow("SERIAL NUMBER", utils.PtrString(zone.SerialNumber))
table.AddSeparator()
- table.AddRow("REFRESH TIME", *zone.RefreshTime)
+ table.AddRow("REFRESH TIME", utils.PtrString(zone.RefreshTime))
table.AddSeparator()
- table.AddRow("RETRY TIME", *zone.RetryTime)
+ table.AddRow("RETRY TIME", utils.PtrString(zone.RetryTime))
table.AddSeparator()
- table.AddRow("EXPIRE TIME", *zone.ExpireTime)
+ table.AddRow("EXPIRE TIME", utils.PtrString(zone.ExpireTime))
table.AddSeparator()
- table.AddRow("NEGATIVE CACHE", *zone.NegativeCache)
+ table.AddRow("NEGATIVE CACHE", utils.PtrString(zone.NegativeCache))
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/dns/zone/describe/describe_test.go b/internal/cmd/dns/zone/describe/describe_test.go
index b5deb3f2c..a5704bfed 100644
--- a/internal/cmd/dns/zone/describe/describe_test.go
+++ b/internal/cmd/dns/zone/describe/describe_test.go
@@ -4,17 +4,17 @@ 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/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/testutils"
"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 +34,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 +101,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 +109,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 +117,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 +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)
})
}
}
@@ -218,3 +171,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ zone *dns.Zone
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only zone as argument",
+ args: args{
+ zone: &dns.Zone{},
+ },
+ 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.zone); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/dns/zone/list/list.go b/internal/cmd/dns/zone/list/list.go
index 46d08b31c..1a2640fe4 100644
--- a/internal/cmd/dns/zone/list/list.go
+++ b/internal/cmd/dns/zone/list/list.go
@@ -2,11 +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"
@@ -16,6 +17,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/dns/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/dns"
@@ -30,6 +32,7 @@ const (
limitFlag = "limit"
pageSizeFlag = "page-size"
+ defaultPage = 1
pageSizeDefault = 100
deleteSucceededState = "DELETE_SUCCEEDED"
)
@@ -46,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",
@@ -68,13 +71,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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
}
@@ -84,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)
@@ -113,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{}
@@ -152,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
}
@@ -181,8 +173,20 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient dnsClient, p
if !model.IncludeDeleted {
req = req.StateNeq(deleteSucceededState)
}
- req = req.PageSize(int32(model.PageSize))
- req = req.Page(int32(page))
+
+ // check integer overflows
+ if model.PageSize > math.MaxInt32 || model.PageSize < math.MinInt32 {
+ req = req.PageSize(pageSizeDefault)
+ } else {
+ req = req.PageSize(int32(model.PageSize))
+ }
+
+ if page > math.MaxInt32 || page < math.MinInt32 {
+ req = req.Page(defaultPage)
+ } else {
+ req = req.Page(int32(page))
+ }
+
return req
}
@@ -222,31 +226,24 @@ 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))
- 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 {
z := zones[i]
- table.AddRow(*z.Id, *z.Name, *z.State, *z.Type, *z.DnsName, *z.RecordCount)
+ table.AddRow(utils.PtrString(z.Id),
+ utils.PtrString(z.Name),
+ utils.PtrString(z.State),
+ utils.PtrString(z.Type),
+ utils.PtrString(z.DnsName),
+ utils.PtrString(z.RecordCount),
+ )
}
err := table.Display(p)
if err != nil {
@@ -254,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 6343d5c4b..e270d69f7 100644
--- a/internal/cmd/dns/zone/list/list_test.go
+++ b/internal/cmd/dns/zone/list/list_test.go
@@ -8,19 +8,19 @@ import (
"strconv"
"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-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,9 +29,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)
@@ -69,6 +69,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
@@ -125,7 +126,7 @@ func TestParseInput(t *testing.T) {
{
description: "required fields only",
flagValues: map[string]string{
- projectIdFlag: testProjectId,
+ globalflags.ProjectIdFlag: testProjectId,
},
isValid: true,
expectedModel: &inputModel{
@@ -139,21 +140,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,
},
@@ -209,46 +210,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)
})
}
}
@@ -483,7 +445,7 @@ func TestFetchZones(t *testing.T) {
}
return
}
- if err == nil && tt.apiCallFails {
+ if tt.apiCallFails {
t.Fatalf("did not fail on invalid input")
}
if numAPICalls != tt.expectedNumAPICalls {
@@ -495,3 +457,31 @@ func TestFetchZones(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ zones []dns.Zone
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.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..a3c31b097 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,7 +100,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Updating zone")
_, err = wait.PartialUpdateZoneWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId).WaitWithContext(ctx)
if err != nil {
@@ -113,7 +113,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 c3578401f..ecfb1240a 100644
--- a/internal/cmd/dns/zone/zone.go
+++ b/internal/cmd/dns/zone/zone.go
@@ -1,19 +1,20 @@
package zone
import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/clone"
"github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/create"
"github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/delete"
"github.com/stackitcloud/stackit-cli/internal/cmd/dns/zone/describe"
"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",
@@ -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(list.NewCmd(p))
- cmd.AddCommand(create.NewCmd(p))
- cmd.AddCommand(describe.NewCmd(p))
- cmd.AddCommand(update.NewCmd(p))
- cmd.AddCommand(delete.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..d28448b6c
--- /dev/null
+++ b/internal/cmd/git/flavor/list/list.go
@@ -0,0 +1,141 @@
+package list
+
+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/git/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/git"
+)
+
+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..66c4ad264
--- /dev/null
+++ b/internal/cmd/git/flavor/list/list_test.go
@@ -0,0 +1,202 @@
+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-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/git"
+)
+
+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..b11bb16a6
--- /dev/null
+++ b/internal/cmd/git/instance/create/create.go
@@ -0,0 +1,159 @@
+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/services/git/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/git"
+ "github.com/stackitcloud/stackit-sdk-go/services/git/wait"
+)
+
+const (
+ nameFlag = "name"
+ flavorFlag = "flavor"
+ aclFlag = "acl"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Id *string
+ 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)
+ }
+ model.Id = result.Id
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Creating stackit git instance")
+ _, err = wait.CreateGitInstanceWaitHandler(ctx, apiClient, model.ProjectId, *model.Id).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for stackit git Instance creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model, 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, model *inputModel, resp *git.Instance) error {
+ if model == nil {
+ return fmt.Errorf("input model is nil")
+ }
+ var outputFormat string
+ if model.GlobalFlagModel != nil {
+ outputFormat = model.OutputFormat
+ }
+
+ return p.OutputResult(outputFormat, resp, func() error {
+ p.Outputf("Created instance %q with id %s\n", model.Name, utils.PtrString(model.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..bde352a95
--- /dev/null
+++ b/internal/cmd/git/instance/create/create_test.go
@@ -0,0 +1,224 @@
+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-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/git"
+)
+
+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 {
+ model *inputModel
+ resp *git.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "nil",
+ args: args{
+ model: nil,
+ resp: nil,
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty input",
+ args: args{
+ model: &inputModel{},
+ resp: &git.Instance{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "output json",
+ args: args{
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ 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.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..7c056829a
--- /dev/null
+++ b/internal/cmd/git/instance/delete/delete.go
@@ -0,0 +1,123 @@
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/git"
+ "github.com/stackitcloud/stackit-sdk-go/services/git/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting stackit git instance")
+ _, err = wait.DeleteGitInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for stackit git instance deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..e90dd0905
--- /dev/null
+++ b/internal/cmd/git/instance/describe/describe.go
@@ -0,0 +1,126 @@
+package describe
+
+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/git/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/git"
+)
+
+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..6a0260f92
--- /dev/null
+++ b/internal/cmd/git/instance/describe/describe_test.go
@@ -0,0 +1,228 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "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 = []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..0f7095199
--- /dev/null
+++ b/internal/cmd/git/instance/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-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"
+ "github.com/stackitcloud/stackit-sdk-go/services/git"
+)
+
+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..459165b87
--- /dev/null
+++ b/internal/cmd/git/instance/list/list_test.go
@@ -0,0 +1,202 @@
+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-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/git"
+)
+
+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
new file mode 100644
index 000000000..e463bf41e
--- /dev/null
+++ b/internal/cmd/image/create/create.go
@@ -0,0 +1,408 @@
+package create
+
+import (
+ "bufio"
+ "context"
+ goerrors "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+
+ "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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ nameFlag = "name"
+ diskFormatFlag = "disk-format"
+ localFilePathFlag = "local-file-path"
+ noProgressIndicatorFlag = "no-progress"
+
+ architectureFlag = "architecture"
+ bootMenuFlag = "boot-menu"
+ cdromBusFlag = "cdrom-bus"
+ diskBusFlag = "disk-bus"
+ nicModelFlag = "nic-model"
+ operatingSystemFlag = "os"
+ operatingSystemDistroFlag = "os-distro"
+ operatingSystemVersionFlag = "os-version"
+ rescueBusFlag = "rescue-bus"
+ rescueDeviceFlag = "rescue-device"
+ secureBootFlag = "secure-boot"
+ uefiFlag = "uefi"
+ videoModelFlag = "video-model"
+ virtioScsiFlag = "virtio-scsi"
+
+ labelsFlag = "labels"
+
+ minDiskSizeFlag = "min-disk-size"
+ minRamFlag = "min-ram"
+ protectedFlag = "protected"
+)
+
+type imageConfig struct {
+ Architecture *string
+ BootMenu *bool
+ CdromBus *string
+ DiskBus *string
+ NicModel *string
+ OperatingSystem *string
+ OperatingSystemDistro *string
+ OperatingSystemVersion *string
+ RescueBus *string
+ RescueDevice *string
+ SecureBoot *bool
+ Uefi bool
+ VideoModel *string
+ VirtioScsi *bool
+}
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ Id *string
+ Name string
+ DiskFormat string
+ LocalFilePath string
+ Labels *map[string]string
+ Config *imageConfig
+ MinDiskSize *int64
+ MinRam *int64
+ Protected *bool
+ NoProgressIndicator *bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates images",
+ Long: "Creates images.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create an image with name 'my-new-image' from a raw disk image located in '/my/raw/image'`,
+ `$ stackit image create --name my-new-image --disk-format=raw --local-file-path=/my/raw/image`,
+ ),
+ examples.NewExample(
+ `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, 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
+ }
+
+ // we open input file first to fail fast, if it is not readable
+ file, err := os.Open(model.LocalFilePath)
+ if err != nil {
+ return fmt.Errorf("create image: file %q is not readable: %w", model.LocalFilePath, err)
+ }
+ defer func() {
+ if inner := file.Close(); inner != nil {
+ err = fmt.Errorf("error closing input file: %w (%w)", inner, 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
+ request := buildRequest(ctx, model, apiClient)
+
+ result, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("create image: %w", err)
+ }
+ model.Id = result.Id
+ url, ok := result.GetUploadUrlOk()
+ if !ok {
+ return fmt.Errorf("create image: no upload URL has been provided")
+ }
+ if err := uploadAsync(ctx, params.Printer, model, file, url); err != nil {
+ return err
+ }
+
+ if err := outputResult(params.Printer, model, result); err != nil {
+ return err
+ }
+
+ return nil
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+func uploadAsync(ctx context.Context, p *print.Printer, model *inputModel, file *os.File, url string) error {
+ stat, err := file.Stat()
+ if err != nil {
+ return fmt.Errorf("upload file: %w", err)
+ }
+
+ var reader io.Reader
+ if model.NoProgressIndicator != nil && *model.NoProgressIndicator {
+ reader = file
+ } else {
+ var ch <-chan int
+ reader, ch = newProgressReader(file)
+ go func() {
+ ticker := time.NewTicker(2 * time.Second)
+ var uploaded int
+ done:
+ for {
+ select {
+ case <-ticker.C:
+ p.Info("uploaded %3.1f%%\r", 100.0/float64(stat.Size())*float64(uploaded))
+ case n, ok := <-ch:
+ if !ok {
+ break done
+ }
+ if n >= 0 {
+ uploaded += n
+ }
+ }
+ }
+ }()
+ }
+
+ if err = uploadFile(ctx, p, reader, stat.Size(), url); err != nil {
+ return fmt.Errorf("upload file: %w", err)
+ }
+
+ return nil
+}
+
+var _ io.Reader = (*progressReader)(nil)
+
+type progressReader struct {
+ delegate io.Reader
+ ch chan int
+}
+
+func newProgressReader(delegate io.Reader) (reader io.Reader, result <-chan int) {
+ ch := make(chan int)
+ return &progressReader{
+ delegate: delegate,
+ ch: ch,
+ }, ch
+}
+
+// Read implements io.Reader.
+func (pr *progressReader) Read(p []byte) (int, error) {
+ n, err := pr.delegate.Read(p)
+ if goerrors.Is(err, io.EOF) && n <= 0 {
+ close(pr.ch)
+ } else {
+ pr.ch <- n
+ }
+ return n, err
+}
+
+func uploadFile(ctx context.Context, p *print.Printer, reader io.Reader, filesize int64, url string) error {
+ p.Debug(print.DebugLevel, "uploading image to %s", url)
+
+ start := time.Now()
+ // pass the file contents as stream, as they can get arbitrarily large. We do
+ // _not_ want to load them into an internal buffer. The downside is, that we
+ // have to set the content-length header manually
+ uploadRequest, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bufio.NewReader(reader))
+ if err != nil {
+ return fmt.Errorf("create image: cannot create request: %w", err)
+ }
+ uploadRequest.Header.Add("Content-Type", "application/octet-stream")
+ uploadRequest.ContentLength = filesize
+
+ uploadResponse, err := http.DefaultClient.Do(uploadRequest)
+ if err != nil {
+ return fmt.Errorf("create image: error contacting server for upload: %w", err)
+ }
+ defer func() {
+ if inner := uploadResponse.Body.Close(); inner != nil {
+ err = fmt.Errorf("error closing file: %w (%w)", inner, err)
+ }
+ }()
+ if uploadResponse.StatusCode != http.StatusOK {
+ return fmt.Errorf("create image: server rejected image upload with %s", uploadResponse.Status)
+ }
+ delay := time.Since(start)
+ p.Debug(print.DebugLevel, "uploaded %d bytes in %v", filesize, delay)
+
+ return nil
+}
+
+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(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.")
+ cmd.Flags().String(nicModelFlag, "", "Sets virtual nic model.")
+ cmd.Flags().String(operatingSystemFlag, "", "Enables OS specific optimizations.")
+ cmd.Flags().String(operatingSystemDistroFlag, "", "Operating System Distribution.")
+ cmd.Flags().String(operatingSystemVersionFlag, "", "Version of the OS.")
+ cmd.Flags().String(rescueBusFlag, "", "Sets the device bus when the image is used as a rescue image.")
+ cmd.Flags().String(rescueDeviceFlag, "", "Sets the device when the image is used as a rescue image.")
+ cmd.Flags().Bool(secureBootFlag, false, "Enables Secure Boot.")
+ cmd.Flags().Bool(uefiFlag, true, "Enables UEFI boot.")
+ cmd.Flags().String(videoModelFlag, "", "Sets Graphic device model.")
+ cmd.Flags().Bool(virtioScsiFlag, false, "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.")
+
+ cmd.Flags().StringToString(labelsFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'")
+
+ cmd.Flags().Int64(minDiskSizeFlag, 0, "Size in Gigabyte.")
+ cmd.Flags().Int64(minRamFlag, 0, "Size in Megabyte.")
+ cmd.Flags().Bool(protectedFlag, false, "Protected VM.")
+
+ if err := flags.MarkFlagsRequired(cmd, nameFlag, diskFormatFlag, localFilePathFlag); err != nil {
+ cobra.CheckErr(err)
+ }
+ cmd.MarkFlagsRequiredTogether(rescueBusFlag, rescueDeviceFlag)
+}
+
+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)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: name,
+ DiskFormat: flags.FlagToStringValue(p, cmd, diskFormatFlag),
+ LocalFilePath: flags.FlagToStringValue(p, cmd, localFilePathFlag),
+ 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),
+ NicModel: flags.FlagToStringPointer(p, cmd, nicModelFlag),
+ OperatingSystem: flags.FlagToStringPointer(p, cmd, operatingSystemFlag),
+ OperatingSystemDistro: flags.FlagToStringPointer(p, cmd, operatingSystemDistroFlag),
+ OperatingSystemVersion: flags.FlagToStringPointer(p, cmd, operatingSystemVersionFlag),
+ RescueBus: flags.FlagToStringPointer(p, cmd, rescueBusFlag),
+ RescueDevice: flags.FlagToStringPointer(p, cmd, rescueDeviceFlag),
+ SecureBoot: flags.FlagToBoolPointer(p, cmd, secureBootFlag),
+ Uefi: flags.FlagToBoolValue(p, cmd, uefiFlag),
+ VideoModel: flags.FlagToStringPointer(p, cmd, videoModelFlag),
+ VirtioScsi: flags.FlagToBoolPointer(p, cmd, virtioScsiFlag),
+ },
+ MinDiskSize: flags.FlagToInt64Pointer(p, cmd, minDiskSizeFlag),
+ MinRam: flags.FlagToInt64Pointer(p, cmd, minRamFlag),
+ Protected: flags.FlagToBoolPointer(p, cmd, protectedFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateImageRequest {
+ request := apiClient.CreateImage(ctx, model.ProjectId, model.Region).
+ CreateImagePayload(createPayload(ctx, model))
+ return request
+}
+
+func createPayload(_ context.Context, model *inputModel) iaas.CreateImagePayload {
+ payload := iaas.CreateImagePayload{
+ DiskFormat: &model.DiskFormat,
+ Name: &model.Name,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ MinDiskSize: model.MinDiskSize,
+ MinRam: model.MinRam,
+ Protected: model.Protected,
+ }
+ 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
+ }
+ }
+
+ return payload
+}
+
+func outputResult(p *print.Printer, model *inputModel, resp *iaas.ImageCreateResponse) error {
+ if model == nil {
+ return fmt.Errorf("input model is nil")
+ }
+ var outputFormat string
+ if model.GlobalFlagModel != nil {
+ outputFormat = model.OutputFormat
+ }
+
+ 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
new file mode 100644
index 000000000..aeefdcdaa
--- /dev/null
+++ b/internal/cmd/image/create/create_test.go
@@ -0,0 +1,403 @@
+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-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"
+ testLocalImagePath = "/does/not/exist"
+ testDiskFormat = "raw"
+ testDiskSize int64 = 16 * 1024 * 1024 * 1024
+ testRamSize int64 = 8 * 1024 * 1024 * 1024
+ testName = "test-image"
+ testProtected = true
+ testCdRomBus = "test-cdrom"
+ testDiskBus = "test-diskbus"
+ testNicModel = "test-nic"
+ testOperatingSystem = "test-os"
+ testOperatingSystemDistro = "test-distro"
+ testOperatingSystemVersion = "test-distro-version"
+ testRescueBus = "test-rescue-bus"
+ testRescueDevice = "test-rescue-device"
+ testArchitecture = "arm64"
+ testBootmenu = true
+ testSecureBoot = true
+ testUefi = true
+ testVideoModel = "test-video-model"
+ testVirtioScsi = true
+ 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{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ nameFlag: testName,
+ diskFormatFlag: testDiskFormat,
+ localFilePathFlag: testLocalImagePath,
+ architectureFlag: testArchitecture,
+ bootMenuFlag: strconv.FormatBool(testBootmenu),
+ cdromBusFlag: testCdRomBus,
+ diskBusFlag: testDiskBus,
+ nicModelFlag: testNicModel,
+ operatingSystemFlag: testOperatingSystem,
+ operatingSystemDistroFlag: testOperatingSystemDistro,
+ operatingSystemVersionFlag: testOperatingSystemVersion,
+ rescueBusFlag: testRescueBus,
+ rescueDeviceFlag: testRescueDevice,
+ secureBootFlag: strconv.FormatBool(testSecureBoot),
+ uefiFlag: strconv.FormatBool(testUefi),
+ videoModelFlag: testVideoModel,
+ virtioScsiFlag: strconv.FormatBool(testVirtioScsi),
+ labelsFlag: testLabels,
+ minDiskSizeFlag: strconv.Itoa(int(testDiskSize)),
+ minRamFlag: strconv.Itoa(int(testRamSize)),
+ protectedFlag: strconv.FormatBool(testProtected),
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func parseLabels(labelstring string) map[string]string {
+ labels := map[string]string{}
+ for _, part := range strings.Split(labelstring, ",") {
+ v := strings.Split(part, "=")
+ labels[v[0]] = v[1]
+ }
+
+ return labels
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ Name: testName,
+ DiskFormat: testDiskFormat,
+ LocalFilePath: testLocalImagePath,
+ Labels: utils.Ptr(parseLabels(testLabels)),
+ Config: &imageConfig{
+ 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: utils.Ptr(testVideoModel),
+ VirtioScsi: utils.Ptr(testVirtioScsi),
+ },
+ MinDiskSize: utils.Ptr(testDiskSize),
+ MinRam: utils.Ptr(testRamSize),
+ Protected: utils.Ptr(testProtected),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureCreatePayload(mods ...func(payload *iaas.CreateImagePayload)) (payload iaas.CreateImagePayload) {
+ payload = iaas.CreateImagePayload{
+ Config: &iaas.ImageConfig{
+ 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: utils.Ptr(testDiskFormat),
+ Labels: &map[string]interface{}{
+ "foo": "FOO",
+ "bar": "BAR",
+ "baz": "BAZ",
+ },
+ MinDiskSize: utils.Ptr(testDiskSize),
+ MinRam: utils.Ptr(testRamSize),
+ Name: utils.Ptr(testName),
+ Protected: utils.Ptr(testProtected),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateImageRequest)) iaas.ApiCreateImageRequest {
+ request := testClient.CreateImage(testCtx, testProjectId, testRegion)
+
+ request = request.CreateImagePayload(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,
+ },
+ {
+ description: "no labels",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelsFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ },
+ {
+ description: "single label",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelsFlag] = "foo=bar"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = &map[string]string{
+ "foo": "bar",
+ }
+ }),
+ },
+ {
+ description: "only rescue bus is invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, rescueDeviceFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "only rescue device is invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, rescueBusFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "uefi flag is set to false",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[uefiFlag] = strconv.FormatBool(false)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Config.Uefi = false
+ }),
+ },
+ {
+ description: "no rescue device and no bus is valid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, rescueBusFlag)
+ delete(flagValues, rescueDeviceFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Config.RescueBus = nil
+ model.Config.RescueDevice = 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.ApiCreateImageRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "no labels",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) {
+ *request = (*request).CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) {
+ payload.Labels = nil
+ }))
+ }),
+ },
+ {
+ description: "cd rom bus",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Config.CdromBus = utils.Ptr("foobar")
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) {
+ *request = (*request).CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) {
+ payload.Config.CdromBus = iaas.NewNullableString(utils.Ptr("foobar"))
+ }))
+ }),
+ },
+ {
+ description: "uefi flag",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Config.Uefi = false
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiCreateImageRequest) {
+ *request = (*request).CreateImagePayload(fixtureCreatePayload(func(payload *iaas.CreateImagePayload) {
+ payload.Config.Uefi = utils.Ptr(false)
+ }))
+ }),
+ },
+ }
+
+ 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(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ resp *iaas.ImageCreateResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "nil",
+ args: args{
+ model: nil,
+ resp: nil,
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty input",
+ args: args{
+ model: &inputModel{},
+ resp: &iaas.ImageCreateResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "output json",
+ args: args{
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ 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.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/image/delete/delete.go b/internal/cmd/image/delete/delete.go
new file mode 100644
index 000000000..c41746f58
--- /dev/null
+++ b/internal/cmd/image/delete/delete.go
@@ -0,0 +1,102 @@
+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"
+ 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/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 {
+ *globalflags.GlobalFlagModel
+ ImageId string
+}
+
+const imageIdArg = "IMAGE_ID"
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", imageIdArg),
+ Short: "Deletes an image",
+ Long: "Deletes an image by its internal ID.",
+ Args: args.SingleArg(imageIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(`Delete an image with ID "xxx"`, `$ stackit image 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
+ }
+
+ imageName, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.Region, model.ImageId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get image name: %v", err)
+ imageName = model.ImageId
+ }
+
+ 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
+ request := buildRequest(ctx, model, apiClient)
+
+ if err := request.Execute(); err != nil {
+ return fmt.Errorf("delete image: %w", err)
+ }
+ params.Printer.Info("Deleted image %q for %q\n", imageName, projectLabel)
+
+ 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, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ImageId: cliArgs[0],
+ }
+
+ 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.Region, model.ImageId)
+ return request
+}
diff --git a/internal/cmd/image/delete/delete_test.go b/internal/cmd/image/delete/delete_test.go
new file mode 100644
index 000000000..200af7e6c
--- /dev/null
+++ b/internal/cmd/image/delete/delete_test.go
@@ -0,0 +1,193 @@
+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/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+ testImageId = 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,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ ImageId: testImageId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteImageRequest)) iaas.ApiDeleteImageRequest {
+ request := testClient.DeleteImage(testCtx, testProjectId, testRegion, testImageId)
+ 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{testImageId},
+ 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 image 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 iaas.ApiDeleteImageRequest
+ }{
+ {
+ 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/image/describe/describe.go b/internal/cmd/image/describe/describe.go
new file mode 100644
index 000000000..09f9f86dc
--- /dev/null
+++ b/internal/cmd/image/describe/describe.go
@@ -0,0 +1,156 @@
+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"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ImageId string
+}
+
+const imageIdArg = "IMAGE_ID"
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", imageIdArg),
+ Short: "Describes image",
+ Long: "Describes an image by its internal ID.",
+ Args: args.SingleArg(imageIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(`Describe image "xxx"`, `$ stackit image 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)
+
+ image, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("get image: %w", err)
+ }
+
+ if err := outputResult(params.Printer, model.OutputFormat, image); err != nil {
+ return err
+ }
+
+ return nil
+ },
+ }
+
+ return cmd
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetImageRequest {
+ request := apiClient.GetImage(ctx, model.ProjectId, model.Region, model.ImageId)
+ return request
+}
+
+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,
+ ImageId: cliArgs[0],
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func outputResult(p *print.Printer, outputFormat string, resp *iaas.Image) error {
+ if resp == nil {
+ return fmt.Errorf("image 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 format := resp.DiskFormat; format != nil {
+ table.AddRow("FORMAT", *format)
+ table.AddSeparator()
+ }
+ if diskSize := resp.MinDiskSize; diskSize != nil {
+ table.AddRow("DISK SIZE", *diskSize)
+ table.AddSeparator()
+ }
+ if ramSize := resp.MinRam; ramSize != nil {
+ table.AddRow("RAM SIZE", *ramSize)
+ 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()
+ }
+ if distro := config.OperatingSystemDistro; distro != nil && distro.IsSet() {
+ table.AddRow("OPERATING SYSTEM DISTRIBUTION", *distro.Get())
+ table.AddSeparator()
+ }
+ if version := config.OperatingSystemVersion; version != nil && version.IsSet() {
+ table.AddRow("OPERATING SYSTEM VERSION", *version.Get())
+ table.AddSeparator()
+ }
+ if uefi := config.Uefi; uefi != nil {
+ table.AddRow("UEFI BOOT", *uefi)
+ table.AddSeparator()
+ }
+ }
+
+ if resp.Labels != nil && len(*resp.Labels) > 0 {
+ labels := []string{}
+ for key, value := range *resp.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ table.AddSeparator()
+ }
+
+ if err := table.Display(p); err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/image/describe/describe_test.go b/internal/cmd/image/describe/describe_test.go
new file mode 100644
index 000000000..256ef1c2a
--- /dev/null
+++ b/internal/cmd/image/describe/describe_test.go
@@ -0,0 +1,238 @@
+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/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()
+ testImageId = []string{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,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ ImageId: testImageId[0],
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetImageRequest)) iaas.ApiGetImageRequest {
+ request := testClient.GetImage(testCtx, testProjectId, testRegion, testImageId[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: testImageId,
+ isValid: true,
+ },
+ {
+ description: "no values",
+ flagValues: map[string]string{},
+ args: testImageId,
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, globalflags.ProjectIdFlag)
+ }),
+ args: testImageId,
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[globalflags.ProjectIdFlag] = ""
+ }),
+ args: testImageId,
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[globalflags.ProjectIdFlag] = "invalid-uuid"
+ }),
+ args: testImageId,
+ isValid: false,
+ },
+ {
+ description: "no image id passed",
+ flagValues: fixtureFlagValues(),
+ args: nil,
+ isValid: false,
+ },
+ {
+ description: "multiple image ids passed",
+ flagValues: fixtureFlagValues(),
+ args: []string{uuid.NewString(), uuid.NewString()},
+ isValid: false,
+ },
+ {
+ description: "invalid image 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 iaas.ApiGetImageRequest
+ }{
+ {
+ 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 *iaas.Image
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ resp: &iaas.Image{},
+ },
+ 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/image/image.go b/internal/cmd/image/image.go
new file mode 100644
index 000000000..65a0cc2a5
--- /dev/null
+++ b/internal/cmd/image/image.go
@@ -0,0 +1,37 @@
+package image
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/image/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/image/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/image/describe"
+ "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/types"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "image",
+ Short: "Manage server images",
+ Long: "Manage the lifecycle of server images.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, params *types.CmdParams) {
+ cmd.AddCommand(
+ 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
new file mode 100644
index 000000000..f30ed8a85
--- /dev/null
+++ b/internal/cmd/image/list/list.go
@@ -0,0 +1,180 @@
+package list
+
+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"
+ "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
+}
+
+const (
+ labelSelectorFlag = "label-selector"
+ limitFlag = "limit"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists images",
+ Long: "Lists images by their internal ID.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all images`,
+ `$ stackit image list`,
+ ),
+ examples.NewExample(
+ `List images with label`,
+ `$ stackit image list --label-selector ARM64,dev`,
+ ),
+ examples.NewExample(
+ `List the first 10 images`,
+ `$ stackit image 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 images: %w", err)
+ }
+
+ if items := response.GetItems(); len(items) == 0 {
+ 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(params.Printer, model.OutputFormat, items); err != nil {
+ return fmt.Errorf("output images: %w", err)
+ }
+ }
+
+ return nil
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+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")
+}
+
+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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ Limit: limit,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListImagesRequest {
+ request := apiClient.ListImages(ctx, model.ProjectId, model.Region)
+ if model.LabelSelector != nil {
+ request = request.LabelSelector(*model.LabelSelector)
+ }
+
+ return request
+}
+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", "ARCHITECTURE", "DISTRIBUTION", "VERSION", "LABELS")
+ for i := range items {
+ item := items[i]
+ var (
+ architecture = "n/a"
+ os = "n/a"
+ distro = "n/a"
+ version = "n/a"
+ )
+ if cfg := item.Config; cfg != nil {
+ if v := cfg.Architecture; v != nil {
+ architecture = *v
+ }
+ if v := cfg.OperatingSystem; v != nil {
+ os = *v
+ }
+ if v := cfg.OperatingSystemDistro; v != nil && v.IsSet() {
+ distro = *v.Get()
+ }
+ if v := cfg.OperatingSystemVersion; v != nil && v.IsSet() {
+ version = *v.Get()
+ }
+ }
+ table.AddRow(utils.PtrString(item.Id),
+ utils.PtrString(item.Name),
+ os,
+ architecture,
+ distro,
+ version,
+ utils.JoinStringKeysPtr(*item.Labels, ","))
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/image/list/list_test.go b/internal/cmd/image/list/list_test.go
new file mode 100644
index 000000000..7521d2023
--- /dev/null
+++ b/internal/cmd/image/list/list_test.go
@@ -0,0 +1,225 @@
+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/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()
+ testLabels = "fooKey=fooValue,barKey=barValue,bazKey=bazValue"
+ testLimit int64 = 10
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ labelSelectorFlag: testLabels,
+ 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{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ LabelSelector: utils.Ptr(testLabels),
+ Limit: &testLimit,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListImagesRequest)) iaas.ApiListImagesRequest {
+ request := testClient.ListImages(testCtx, testProjectId, testRegion)
+ request = request.LabelSelector(testLabels)
+ 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: "no labels",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelSelectorFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = nil
+ }),
+ },
+ {
+ description: "single label",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = "foo=bar"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("foo=bar")
+ }),
+ },
+ }
+
+ 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.ApiListImagesRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+
+ {
+ description: "no labels",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiListImagesRequest) {
+ *request = (*request).LabelSelector("")
+ }),
+ },
+ {
+ description: "single label",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("foo=bar")
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiListImagesRequest) {
+ *request = (*request).LabelSelector("foo=bar")
+ }),
+ },
+ }
+
+ 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
+ items []iaas.Image
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ outputFormat: "",
+ items: []iaas.Image{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "output format json",
+ args: args{
+ outputFormat: print.JSONOutputFormat,
+ items: []iaas.Image{},
+ },
+ 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.items); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/image/update/update.go b/internal/cmd/image/update/update.go
new file mode 100644
index 000000000..26d8ca088
--- /dev/null
+++ b/internal/cmd/image/update/update.go
@@ -0,0 +1,298 @@
+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"
+ 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/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
+ NicModel *string
+ OperatingSystem *string
+ OperatingSystemDistro *string
+ OperatingSystemVersion *string
+ RescueBus *string
+ RescueDevice *string
+ SecureBoot *bool
+ Uefi *bool
+ VideoModel *string
+ VirtioScsi *bool
+}
+
+func (ic *imageConfig) isEmpty() bool {
+ return ic.BootMenu == nil &&
+ ic.CdromBus == nil &&
+ ic.DiskBus == nil &&
+ ic.NicModel == nil &&
+ ic.OperatingSystem == nil &&
+ ic.OperatingSystemDistro == nil &&
+ ic.OperatingSystemVersion == nil &&
+ ic.RescueBus == nil &&
+ ic.RescueDevice == nil &&
+ ic.SecureBoot == nil &&
+ ic.Uefi == nil &&
+ ic.VideoModel == nil &&
+ ic.VirtioScsi == nil
+}
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ Id string
+ Name *string
+ DiskFormat *string
+ Labels *map[string]string
+ Config *imageConfig
+ MinDiskSize *int64
+ MinRam *int64
+ Protected *bool
+}
+
+func (im *inputModel) isEmpty() bool {
+ return im.Name == nil &&
+ im.DiskFormat == nil &&
+ im.Labels == nil &&
+ (im.Config == nil || im.Config.isEmpty()) &&
+ im.MinDiskSize == nil &&
+ im.MinRam == nil &&
+ im.Protected == nil
+}
+
+const imageIdArg = "IMAGE_ID"
+
+const (
+ nameFlag = "name"
+ diskFormatFlag = "disk-format"
+
+ architectureFlag = "architecture"
+ bootMenuFlag = "boot-menu"
+ cdromBusFlag = "cdrom-bus"
+ diskBusFlag = "disk-bus"
+ nicModelFlag = "nic-model"
+ operatingSystemFlag = "os"
+ operatingSystemDistroFlag = "os-distro"
+ operatingSystemVersionFlag = "os-version"
+ rescueBusFlag = "rescue-bus"
+ rescueDeviceFlag = "rescue-device"
+ secureBootFlag = "secure-boot"
+ uefiFlag = "uefi"
+ videoModelFlag = "video-model"
+ virtioScsiFlag = "virtio-scsi"
+
+ labelsFlag = "labels"
+
+ minDiskSizeFlag = "min-disk-size"
+ minRamFlag = "min-ram"
+ protectedFlag = "protected"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", imageIdArg),
+ Short: "Updates an image",
+ Long: "Updates an image",
+ Args: args.SingleArg(imageIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(`Update the name of an image with ID "xxx"`, `$ stackit image update xxx --name my-new-name`),
+ examples.NewExample(`Update the labels of an image with ID "xxx"`, `$ stackit image update xxx --labels label1=value1,label2=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
+ }
+
+ imageLabel, err := iaasUtils.GetImageName(ctx, apiClient, model.ProjectId, model.Region, model.Id)
+ if err != nil {
+ params.Printer.Debug(print.WarningLevel, "cannot retrieve image name: %v", err)
+ imageLabel = model.Id
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update image: %w", err)
+ }
+ params.Printer.Info("Updated image \"%v\" for %q\n", utils.PtrString(resp.Name), projectLabel)
+
+ return nil
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+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.")
+ cmd.Flags().String(nicModelFlag, "", "Sets virtual nic model.")
+ cmd.Flags().String(operatingSystemFlag, "", "Enables OS specific optimizations.")
+ cmd.Flags().String(operatingSystemDistroFlag, "", "Operating System Distribution.")
+ cmd.Flags().String(operatingSystemVersionFlag, "", "Version of the OS.")
+ cmd.Flags().String(rescueBusFlag, "", "Sets the device bus when the image is used as a rescue image.")
+ cmd.Flags().String(rescueDeviceFlag, "", "Sets the device when the image is used as a rescue image.")
+ cmd.Flags().Bool(secureBootFlag, false, "Enables Secure Boot.")
+ cmd.Flags().Bool(uefiFlag, false, "Enables UEFI boot.")
+ cmd.Flags().String(videoModelFlag, "", "Sets Graphic device model.")
+ cmd.Flags().Bool(virtioScsiFlag, false, "Enables the use of VirtIO SCSI to provide block device access. By default instances use VirtIO Block.")
+
+ cmd.Flags().StringToString(labelsFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'")
+
+ cmd.Flags().Int64(minDiskSizeFlag, 0, "Size in Gigabyte.")
+ cmd.Flags().Int64(minRamFlag, 0, "Size in Megabyte.")
+ cmd.Flags().Bool(protectedFlag, false, "Protected VM.")
+
+ cmd.MarkFlagsRequiredTogether(rescueBusFlag, rescueDeviceFlag)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, cliArgs []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Id: cliArgs[0],
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+
+ 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),
+ NicModel: flags.FlagToStringPointer(p, cmd, nicModelFlag),
+ OperatingSystem: flags.FlagToStringPointer(p, cmd, operatingSystemFlag),
+ OperatingSystemDistro: flags.FlagToStringPointer(p, cmd, operatingSystemDistroFlag),
+ OperatingSystemVersion: flags.FlagToStringPointer(p, cmd, operatingSystemVersionFlag),
+ RescueBus: flags.FlagToStringPointer(p, cmd, rescueBusFlag),
+ RescueDevice: flags.FlagToStringPointer(p, cmd, rescueDeviceFlag),
+ SecureBoot: flags.FlagToBoolPointer(p, cmd, secureBootFlag),
+ Uefi: flags.FlagToBoolPointer(p, cmd, uefiFlag),
+ VideoModel: flags.FlagToStringPointer(p, cmd, videoModelFlag),
+ VirtioScsi: flags.FlagToBoolPointer(p, cmd, virtioScsiFlag),
+ },
+ MinDiskSize: flags.FlagToInt64Pointer(p, cmd, minDiskSizeFlag),
+ MinRam: flags.FlagToInt64Pointer(p, cmd, minRamFlag),
+ Protected: flags.FlagToBoolPointer(p, cmd, protectedFlag),
+ }
+
+ if model.isEmpty() {
+ return nil, fmt.Errorf("no flags have been passed")
+ }
+
+ 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.Region, model.Id)
+ payload := iaas.NewUpdateImagePayload()
+
+ // Config *ImageConfig `json:"config,omitempty"`
+ payload.DiskFormat = model.DiskFormat
+ 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 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
+ }
+ }
+
+ request = request.UpdateImagePayload(*payload)
+
+ return request
+}
diff --git a/internal/cmd/image/update/update_test.go b/internal/cmd/image/update/update_test.go
new file mode 100644
index 000000000..9246bd67b
--- /dev/null
+++ b/internal/cmd/image/update/update_test.go
@@ -0,0 +1,451 @@
+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"
+
+ "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()
+
+ testImageId = []string{uuid.NewString()}
+ testDiskFormat = "raw"
+ testDiskSize int64 = 16 * 1024 * 1024 * 1024
+ testRamSize int64 = 8 * 1024 * 1024 * 1024
+ testName = "test-image"
+ testProtected = true
+ testCdRomBus = "test-cdrom"
+ testDiskBus = "test-diskbus"
+ testNicModel = "test-nic"
+ testOperatingSystem = "test-os"
+ testOperatingSystemDistro = "test-distro"
+ testOperatingSystemVersion = "test-distro-version"
+ testRescueBus = "test-rescue-bus"
+ testRescueDevice = "test-rescue-device"
+ testBootmenu = true
+ testSecureBoot = true
+ testUefi = true
+ testVideoModel = "test-video-model"
+ testVirtioScsi = true
+ testLabels = "foo=FOO,bar=BAR,baz=BAZ"
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ nameFlag: testName,
+ diskFormatFlag: testDiskFormat,
+ bootMenuFlag: strconv.FormatBool(testBootmenu),
+ cdromBusFlag: testCdRomBus,
+ diskBusFlag: testDiskBus,
+ nicModelFlag: testNicModel,
+ operatingSystemFlag: testOperatingSystem,
+ operatingSystemDistroFlag: testOperatingSystemDistro,
+ operatingSystemVersionFlag: testOperatingSystemVersion,
+ rescueBusFlag: testRescueBus,
+ rescueDeviceFlag: testRescueDevice,
+ secureBootFlag: strconv.FormatBool(testSecureBoot),
+ uefiFlag: strconv.FormatBool(testUefi),
+ videoModelFlag: testVideoModel,
+ virtioScsiFlag: strconv.FormatBool(testVirtioScsi),
+ labelsFlag: testLabels,
+ minDiskSizeFlag: strconv.Itoa(int(testDiskSize)),
+ minRamFlag: strconv.Itoa(int(testRamSize)),
+ protectedFlag: strconv.FormatBool(testProtected),
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func parseLabels(labelstring string) map[string]string {
+ labels := map[string]string{}
+ for _, part := range strings.Split(labelstring, ",") {
+ v := strings.Split(part, "=")
+ labels[v[0]] = v[1]
+ }
+
+ return labels
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ 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,
+ DiskBus: &testDiskBus,
+ NicModel: &testNicModel,
+ OperatingSystem: &testOperatingSystem,
+ OperatingSystemDistro: &testOperatingSystemDistro,
+ OperatingSystemVersion: &testOperatingSystemVersion,
+ RescueBus: &testRescueBus,
+ RescueDevice: &testRescueDevice,
+ SecureBoot: &testSecureBoot,
+ Uefi: &testUefi,
+ VideoModel: &testVideoModel,
+ VirtioScsi: &testVirtioScsi,
+ },
+ MinDiskSize: &testDiskSize,
+ MinRam: &testRamSize,
+ Protected: &testProtected,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureCreatePayload(mods ...func(payload *iaas.UpdateImagePayload)) (payload iaas.UpdateImagePayload) {
+ payload = iaas.UpdateImagePayload{
+ 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,
+ },
+ DiskFormat: &testDiskFormat,
+ Labels: &map[string]interface{}{
+ "foo": "FOO",
+ "bar": "BAR",
+ "baz": "BAZ",
+ },
+ MinDiskSize: &testDiskSize,
+ MinRam: &testRamSize,
+ Name: &testName,
+ Protected: &testProtected,
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(*iaas.ApiUpdateImageRequest)) iaas.ApiUpdateImageRequest {
+ request := testClient.UpdateImage(testCtx, testProjectId, testRegion, testImageId[0])
+
+ request = request.UpdateImagePayload(fixtureCreatePayload())
+
+ 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(),
+ isValid: true,
+ args: testImageId,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values but valid image id",
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ },
+ args: testImageId,
+ isValid: false,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ model.Name = nil
+ }),
+ },
+ {
+ description: "project id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, globalflags.ProjectIdFlag)
+ }),
+ args: testImageId,
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[globalflags.ProjectIdFlag] = ""
+ }),
+ args: testImageId,
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[globalflags.ProjectIdFlag] = "invalid-uuid"
+ }),
+ args: testImageId,
+ isValid: false,
+ },
+ {
+ description: "no name passed",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, nameFlag)
+ }),
+ args: testImageId,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Name = nil
+ }),
+ isValid: true,
+ },
+ {
+ description: "no labels",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelsFlag)
+ }),
+ args: testImageId,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ isValid: true,
+ },
+ {
+ description: "single label",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelsFlag] = "foo=bar"
+ }),
+ args: testImageId,
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = &map[string]string{
+ "foo": "bar",
+ }
+ }),
+ },
+ {
+ description: "no image id passed",
+ flagValues: fixtureFlagValues(),
+ args: nil,
+ isValid: false,
+ },
+ {
+ description: "invalid image id passed",
+ flagValues: fixtureFlagValues(),
+ args: []string{"foobar"},
+ isValid: false,
+ },
+ {
+ description: "multiple image ids passed",
+ flagValues: fixtureFlagValues(),
+ args: []string{uuid.NewString(), uuid.NewString()},
+ isValid: false,
+ },
+ {
+ description: "only rescue bus is invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, rescueDeviceFlag)
+ }),
+ args: []string{testImageId[0]},
+ isValid: false,
+ },
+ {
+ description: "only rescue device is invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, rescueBusFlag)
+ }),
+ args: []string{testImageId[0]},
+ isValid: false,
+ },
+ {
+ description: "no rescue device and no bus is valid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, rescueBusFlag)
+ delete(flagValues, rescueDeviceFlag)
+ }),
+ isValid: true,
+ args: []string{testImageId[0]},
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Config.RescueBus = nil
+ 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(&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 {
+ 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)
+ }
+
+ if err := cmd.ValidateFlagGroups(); err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flag groups: %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 iaas.ApiUpdateImageRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "no labels",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) {
+ *request = (*request).UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) {
+ payload.Labels = nil
+ }))
+ }),
+ },
+ {
+ description: "change name",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Name = utils.Ptr("something else")
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) {
+ *request = (*request).UpdateImagePayload(fixtureCreatePayload(func(payload *iaas.UpdateImagePayload) {
+ payload.Name = utils.Ptr("something else")
+ }))
+ }),
+ },
+ {
+ description: "change cdrom",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Config.CdromBus = utils.Ptr("something else")
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateImageRequest) {
+ *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 {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest, iaas.NullableString{}),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/key-pair/create/create.go b/internal/cmd/key-pair/create/create.go
new file mode 100644
index 000000000..5bb18ef2e
--- /dev/null
+++ b/internal/cmd/key-pair/create/create.go
@@ -0,0 +1,137 @@
+package create
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ nameFlag = "name"
+ publicKeyFlag = "public-key"
+ labelFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Name *string
+ PublicKey *string
+ Labels *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a key pair",
+ Long: "Creates a key pair.",
+ Args: cobra.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a new key pair with public-key "ssh-rsa xxx"`,
+ "$ stackit key-pair create --public-key `ssh-rsa xxx`",
+ ),
+ examples.NewExample(
+ `Create a new key pair with public-key from file "/Users/username/.ssh/id_rsa.pub"`,
+ "$ stackit key-pair create --public-key `@/Users/username/.ssh/id_rsa.pub`",
+ ),
+ examples.NewExample(
+ `Create a new key pair with name "KEY_PAIR_NAME" and public-key "ssh-rsa yyy"`,
+ "$ stackit key-pair create --name KEY_PAIR_NAME --public-key `ssh-rsa yyy`",
+ ),
+ examples.NewExample(
+ `Create a new key pair with public-key "ssh-rsa xxx" and labels "key=value,key1=value1"`,
+ "$ stackit key-pair create --public-key `ssh-rsa xxx` --labels key=value,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 client
+ apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create key pair: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(nameFlag, "", "Key pair name")
+ cmd.Flags().Var(flags.ReadFromFileFlag(), publicKeyFlag, "Public key to be imported (format: ssh-rsa|ssh-ed25519)")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a key pair. E.g. '--labels key1=value1,key2=value2,...'")
+
+ err := cmd.MarkFlagRequired(publicKeyFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, _ []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ PublicKey: flags.FlagToStringPointer(p, cmd, publicKeyFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateKeyPairRequest {
+ req := apiClient.CreateKeyPair(ctx)
+
+ payload := iaas.CreateKeyPairPayload{
+ Name: model.Name,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ PublicKey: model.PublicKey,
+ }
+
+ return req.CreateKeyPairPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat string, item *iaas.Keypair) error {
+ if item == nil {
+ return fmt.Errorf("no key pair found")
+ }
+
+ 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
+ })
+}
diff --git a/internal/cmd/key-pair/create/create_test.go b/internal/cmd/key-pair/create/create_test.go
new file mode 100644
index 000000000..24418d845
--- /dev/null
+++ b/internal/cmd/key-pair/create/create_test.go
@@ -0,0 +1,200 @@
+package create
+
+import (
+ "context"
+ "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"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testPublicKey = "ssh-rsa "
+var testKeyPairName = "foobar_key"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ publicKeyFlag: testPublicKey,
+ labelFlag: "foo=bar",
+ nameFlag: testKeyPairName,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Labels: utils.Ptr(map[string]string{
+ "foo": "bar",
+ }),
+ PublicKey: utils.Ptr(testPublicKey),
+ Name: utils.Ptr(testKeyPairName),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateKeyPairRequest)) iaas.ApiCreateKeyPairRequest {
+ request := testClient.CreateKeyPair(testCtx)
+ request = request.CreateKeyPairPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateKeyPairPayload)) iaas.CreateKeyPairPayload {
+ payload := iaas.CreateKeyPairPayload{
+ Labels: utils.Ptr(map[string]interface{}{
+ "foo": "bar",
+ }),
+ PublicKey: utils.Ptr(testPublicKey),
+ Name: utils.Ptr(testKeyPairName),
+ }
+ 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, nameFlag)
+ delete(flagValues, labelFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Name = nil
+ model.Labels = nil
+ }),
+ },
+ {
+ description: "read public key from file",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[publicKeyFlag] = "@./template/id_ed25519.pub"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ file, err := os.ReadFile("./template/id_ed25519.pub")
+ if err != nil {
+ t.Fatal("could not create expected Model", err)
+ }
+ model.PublicKey = utils.Ptr(string(file))
+ }),
+ },
+ {
+ description: "no values",
+ 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.ApiCreateKeyPairRequest
+ }{
+ {
+ 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(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ item *iaas.Keypair
+ outputFormat string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ item: nil,
+ outputFormat: "",
+ },
+ wantErr: true,
+ },
+ {
+ name: "base",
+ args: args{
+ item: &iaas.Keypair{},
+ outputFormat: "",
+ },
+ 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.item); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/key-pair/create/template/id_ed25519.pub b/internal/cmd/key-pair/create/template/id_ed25519.pub
new file mode 100644
index 000000000..082c95349
--- /dev/null
+++ b/internal/cmd/key-pair/create/template/id_ed25519.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG1ogKtJ5SElBm3mxhFhdvXxXiz+FxYoOvcdWSW2/ZI
diff --git a/internal/cmd/key-pair/delete/delete.go b/internal/cmd/key-pair/delete/delete.go
new file mode 100644
index 000000000..17984e936
--- /dev/null
+++ b/internal/cmd/key-pair/delete/delete.go
@@ -0,0 +1,90 @@
+package delete
+
+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"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ keyPairNameArg = "KEY_PAIR_NAME"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ KeyPairName string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", keyPairNameArg),
+ Short: "Deletes a key pair",
+ Long: "Deletes a key pair.",
+ Args: args.SingleArg(keyPairNameArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete key pair with name "KEY_PAIR_NAME"`,
+ "$ stackit key-pair delete KEY_PAIR_NAME",
+ ),
+ ),
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete key pair: %w", err)
+ }
+
+ params.Printer.Info("Deleted key pair %q\n", model.KeyPairName)
+
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ keyPairName := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ KeyPairName: keyPairName,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteKeyPairRequest {
+ return apiClient.DeleteKeyPair(ctx, model.KeyPairName)
+}
diff --git a/internal/cmd/key-pair/delete/delete_test.go b/internal/cmd/key-pair/delete/delete_test.go
new file mode 100644
index 000000000..bb45798c9
--- /dev/null
+++ b/internal/cmd/key-pair/delete/delete_test.go
@@ -0,0 +1,178 @@
+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/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "test")
+var testClient = &iaas.APIClient{}
+var testKeyPairName = "key-pair-name"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testKeyPairName,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{}
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ KeyPairName: testKeyPairName,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteKeyPairRequest)) iaas.ApiDeleteKeyPairRequest {
+ request := testClient.DeleteKeyPair(testCtx, testKeyPairName)
+ 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 args",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flags",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ }
+
+ 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 input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiDeleteKeyPairRequest
+ }{
+ {
+ 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/key-pair/describe/describe.go b/internal/cmd/key-pair/describe/describe.go
new file mode 100644
index 000000000..40f450949
--- /dev/null
+++ b/internal/cmd/key-pair/describe/describe.go
@@ -0,0 +1,181 @@
+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"
+ "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/goccy/go-yaml"
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ keyPairNameArg = "KEY_PAIR_NAME"
+
+ publicKeyFlag = "public-key"
+
+ maxLengthPublicKey = 50
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ KeyPairName string
+ PublicKey bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", keyPairNameArg),
+ Short: "Describes a key pair",
+ Long: "Describes a key pair.",
+ Args: args.SingleArg(keyPairNameArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details about a key pair with name "KEY_PAIR_NAME"`,
+ "$ stackit key-pair describe KEY_PAIR_NAME",
+ ),
+ examples.NewExample(
+ `Get only the SSH public key of a key pair with name "KEY_PAIR_NAME"`,
+ "$ stackit key-pair describe KEY_PAIR_NAME --public-key",
+ ),
+ ),
+ 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 key pair: %w", err)
+ }
+
+ if keypair := resp; keypair != nil {
+ return outputResult(params.Printer, model.OutputFormat, model.PublicKey, *keypair)
+ }
+ params.Printer.Outputln("No keypair found.")
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Bool(publicKeyFlag, false, "Show only the public key")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ keyPairName := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ KeyPairName: keyPairName,
+ PublicKey: flags.FlagToBoolValue(p, cmd, publicKeyFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetKeyPairRequest {
+ return apiClient.GetKeyPair(ctx, model.KeyPairName)
+}
+
+func outputResult(p *print.Printer, outputFormat string, showOnlyPublicKey bool, keyPair iaas.Keypair) error {
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(keyPair, "", " ")
+ if showOnlyPublicKey {
+ onlyPublicKey := map[string]string{
+ "publicKey": *keyPair.PublicKey,
+ }
+ details, err = json.MarshalIndent(onlyPublicKey, "", " ")
+ }
+
+ if err != nil {
+ return fmt.Errorf("marshal key pair: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(keyPair, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
+ if showOnlyPublicKey {
+ onlyPublicKey := map[string]string{
+ "publicKey": *keyPair.PublicKey,
+ }
+ details, err = yaml.MarshalWithOptions(onlyPublicKey, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
+ }
+
+ if err != nil {
+ return fmt.Errorf("marshal key pair: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ if showOnlyPublicKey {
+ p.Outputln(*keyPair.PublicKey)
+ return nil
+ }
+ table := tables.NewTable()
+ table.AddRow("KEY PAIR NAME", utils.PtrString(keyPair.Name))
+ table.AddSeparator()
+
+ if keyPair.Labels != nil && len(*keyPair.Labels) > 0 {
+ var labels []string
+ for key, value := range *keyPair.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ table.AddSeparator()
+ }
+
+ table.AddRow("FINGERPRINT", utils.PtrString(keyPair.Fingerprint))
+ table.AddSeparator()
+
+ truncatedPublicKey := ""
+ if keyPair.PublicKey != nil {
+ truncatedPublicKey = (*keyPair.PublicKey)[:maxLengthPublicKey] + "..."
+ }
+
+ table.AddRow("PUBLIC KEY", truncatedPublicKey)
+ table.AddSeparator()
+
+ table.AddRow("CREATED AT", utils.PtrString(keyPair.CreatedAt))
+ table.AddSeparator()
+
+ table.AddRow("UPDATED AT", utils.PtrString(keyPair.UpdatedAt))
+ table.AddSeparator()
+
+ p.Outputln(table.Render())
+ }
+
+ return nil
+}
diff --git a/internal/cmd/key-pair/describe/describe_test.go b/internal/cmd/key-pair/describe/describe_test.go
new file mode 100644
index 000000000..b94ae4ece
--- /dev/null
+++ b/internal/cmd/key-pair/describe/describe_test.go
@@ -0,0 +1,176 @@
+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/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "test")
+var testClient = &iaas.APIClient{}
+var testKeyPairName = "foobar"
+var testPublicKeyFlag = "true"
+
+func fixtureArgValues(mods ...func(argVales []string)) []string {
+ argVales := []string{
+ testKeyPairName,
+ }
+ for _, m := range mods {
+ m(argVales)
+ }
+ return argVales
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{}
+ for _, m := range mods {
+ m(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ KeyPairName: testKeyPairName,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetKeyPairRequest)) iaas.ApiGetKeyPairRequest {
+ request := testClient.GetKeyPair(testCtx, testKeyPairName)
+ 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{},
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argsValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ argsValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "set flag 'public-key' true",
+ argsValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[publicKeyFlag] = testPublicKeyFlag
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.PublicKey = true
+ }),
+ },
+ }
+
+ 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 iaas.ApiGetKeyPairRequest
+ }{
+ {
+ 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)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ showOnlyPublicKey bool
+ keyPair iaas.Keypair
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ outputFormat: "",
+ showOnlyPublicKey: false,
+ keyPair: iaas.Keypair{},
+ },
+ 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.showOnlyPublicKey, tt.args.keyPair); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/key-pair/key-pair.go b/internal/cmd/key-pair/key-pair.go
new file mode 100644
index 000000000..e435a27df
--- /dev/null
+++ b/internal/cmd/key-pair/key-pair.go
@@ -0,0 +1,33 @@
+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/utils"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "key-pair",
+ Short: "Provides functionality for SSH key pairs",
+ Long: "Provides functionality for SSH key pairs",
+ 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/key-pair/list/list.go b/internal/cmd/key-pair/list/list.go
new file mode 100644
index 000000000..3820eb038
--- /dev/null
+++ b/internal/cmd/key-pair/list/list.go
@@ -0,0 +1,158 @@
+package list
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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 key pairs",
+ Long: "Lists all key pairs.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all key pairs`,
+ "$ stackit key-pair list",
+ ),
+ examples.NewExample(
+ `Lists all key pairs which contains the label xxx`,
+ "$ stackit key-pair list --label-selector xxx",
+ ),
+ examples.NewExample(
+ `Lists all key pairs in JSON format`,
+ "$ stackit key-pair list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 key pairs`,
+ "$ stackit key-pair 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("list key pairs: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ params.Printer.Info("No key pairs found\n")
+ return nil
+ }
+
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Number of key pairs to list")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListKeyPairsRequest {
+ req := apiClient.ListKeyPairs(ctx)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, keyPairs []iaas.Keypair) error {
+ return p.OutputResult(outputFormat, keyPairs, func() error {
+ table := tables.NewTable()
+ table.SetHeader("KEY PAIR NAME", "LABELS", "FINGERPRINT", "CREATED AT", "UPDATED AT")
+
+ for idx := range keyPairs {
+ keyPair := keyPairs[idx]
+
+ var labels []string
+ if keyPair.Labels != nil {
+ for key, value := range *keyPair.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ }
+
+ table.AddRow(
+ utils.PtrString(keyPair.Name),
+ strings.Join(labels, ", "),
+ utils.PtrString(keyPair.Fingerprint),
+ utils.PtrString(keyPair.CreatedAt),
+ utils.PtrString(keyPair.UpdatedAt),
+ )
+ }
+
+ p.Outputln(table.Render())
+ return nil
+ })
+}
diff --git a/internal/cmd/key-pair/list/list_test.go b/internal/cmd/key-pair/list/list_test.go
new file mode 100644
index 000000000..2ceb0d426
--- /dev/null
+++ b/internal/cmd/key-pair/list/list_test.go
@@ -0,0 +1,187 @@
+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/iaas"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "test")
+var testClient = &iaas.APIClient{}
+var testLabelSelector = "foo=bar"
+var testLimit = int64(64)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ limitFlag: strconv.FormatInt(testLimit, 10),
+ labelSelectorFlag: testLabelSelector,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Limit: utils.Ptr(testLimit),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListKeyPairsRequest)) iaas.ApiListKeyPairsRequest {
+ request := testClient.ListKeyPairs(testCtx)
+ request = request.LabelSelector(testLabelSelector)
+ 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: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.Limit = nil
+ inputModel.LabelSelector = nil
+ }),
+ },
+ {
+ description: "withoutLimit",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, "limit")
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.Limit = nil
+ }),
+ },
+ {
+ description: "invalid limit 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[limitFlag] = "invalid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "invalid limit 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[limitFlag] = "0"
+ }),
+ isValid: false,
+ },
+ {
+ description: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ 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.ApiListKeyPairsRequest
+ }{
+ {
+ 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("request does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ keyPairs []iaas.Keypair
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ outputFormat: "",
+ keyPairs: []iaas.Keypair{
+ {},
+ },
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ p := print.NewPrinter()
+ 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
new file mode 100644
index 000000000..14988bf28
--- /dev/null
+++ b/internal/cmd/key-pair/update/update.go
@@ -0,0 +1,117 @@
+package update
+
+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"
+ "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"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ keyPairNameArg = "KEY_PAIR_NAME"
+ labelsFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Labels *map[string]string
+ KeyPairName *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", keyPairNameArg),
+ Short: "Updates a key pair",
+ Long: "Updates a key pair.",
+ Args: args.SingleArg(keyPairNameArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update the labels of a key pair with name "KEY_PAIR_NAME" with "key=value,key1=value1"`,
+ "$ stackit key-pair update KEY_PAIR_NAME --labels key=value,key1=value1",
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+ model := parseInput(params.Printer, cmd, args)
+
+ // 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 update key pair %q?", *model.KeyPairName)
+ err = params.Printer.PromptForConfirmation(prompt)
+ if err != nil {
+ return fmt.Errorf("update key pair: %w", err)
+ }
+
+ // Call API
+ req := buildRequest(ctx, &model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update key pair: %w", err)
+ }
+ if resp == nil {
+ return fmt.Errorf("response is nil")
+ }
+
+ return outputResult(params.Printer, model, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringToString(labelsFlag, nil, "Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...'")
+
+ err := cmd.MarkFlagRequired(labelsFlag)
+ cobra.CheckErr(err)
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateKeyPairRequest {
+ req := apiClient.UpdateKeyPair(ctx, *model.KeyPairName)
+
+ payload := iaas.UpdateKeyPairPayload{
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+ return req.UpdateKeyPairPayload(payload)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) inputModel {
+ keyPairName := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag),
+ KeyPairName: utils.Ptr(keyPairName),
+ }
+
+ 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.OutputFormat
+ }
+
+ return p.OutputResult(outputFormat, keyPair, func() error {
+ p.Outputf("Updated labels of key pair %q\n", utils.PtrString(model.KeyPairName))
+ return nil
+ })
+}
diff --git a/internal/cmd/key-pair/update/update_test.go b/internal/cmd/key-pair/update/update_test.go
new file mode 100644
index 000000000..7f24c935e
--- /dev/null
+++ b/internal/cmd/key-pair/update/update_test.go
@@ -0,0 +1,221 @@
+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/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testKeyPairName = "foobar_key"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testKeyPairName,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ labelsFlag: "foo=bar",
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Labels: utils.Ptr(map[string]string{
+ "foo": "bar",
+ }),
+ KeyPairName: utils.Ptr(testKeyPairName),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdateKeyPairRequest)) iaas.ApiUpdateKeyPairRequest {
+ request := testClient.UpdateKeyPair(testCtx, testKeyPairName)
+ request = request.UpdateKeyPairPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdateKeyPairPayload)) iaas.UpdateKeyPairPayload {
+ payload := iaas.UpdateKeyPairPayload{
+ Labels: utils.Ptr(map[string]interface{}{
+ "foo": "bar",
+ }),
+ }
+ 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 flags",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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 := parseInput(p, cmd, tt.argValues)
+
+ if !tt.isValid {
+ 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 iaas.ApiUpdateKeyPairRequest
+ }{
+ {
+ 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(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ model inputModel
+ keyPair iaas.Keypair
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "base",
+ args: args{
+ model: inputModel{},
+ keyPair: iaas.Keypair{},
+ },
+ 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.keyPair); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/load-balancer/create/create.go b/internal/cmd/load-balancer/create/create.go
index 324948fff..31e74d9ba 100644
--- a/internal/cmd/load-balancer/create/create.go
+++ b/internal/cmd/load-balancer/create/create.go
@@ -5,21 +5,21 @@ import (
"encoding/json"
"fmt"
- "github.com/google/uuid"
-
- "github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
- "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+ "github.com/google/uuid"
+ "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/load-balancer/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
-
- "github.com/spf13/cobra"
+ "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"
)
@@ -36,7 +36,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",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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
@@ -95,9 +93,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating load balancer")
- _, err = wait.CreateLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, *model.Payload.Name).WaitWithContext(ctx)
+ _, err = wait.CreateLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *model.Payload.Name).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for load balancer creation: %w", err)
}
@@ -108,7 +106,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
if model.Async {
operationState = "Triggered creation of"
}
- p.Outputf("%s load balancer with name %q \n", operationState, *model.Payload.Name)
+ params.Printer.Outputf("%s load balancer with name %q \n", operationState, utils.PtrString(model.Payload.Name))
return nil
},
}
@@ -123,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{}
@@ -144,20 +142,12 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiCreateLoadBalancerRequest {
- req := apiClient.CreateLoadBalancer(ctx, model.ProjectId)
+ req := apiClient.CreateLoadBalancer(ctx, model.ProjectId, model.Region)
req = req.CreateLoadBalancerPayload(*model.Payload)
req = req.XRequestID(xRequestId)
return req
diff --git a/internal/cmd/load-balancer/create/create_test.go b/internal/cmd/load-balancer/create/create_test.go
index 09dae8cc1..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"
@@ -15,14 +15,18 @@ import (
"github.com/google/uuid"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+)
type testCtxKey struct{}
-var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &loadbalancer.APIClient{}
-var testProjectId = uuid.NewString()
-var testRequestId = xRequestId
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &loadbalancer.APIClient{}
+ testProjectId = uuid.NewString()
+ testRequestId = xRequestId
+)
var testPayload = &loadbalancer.CreateLoadBalancerPayload{
ExternalAddress: utils.Ptr(""),
@@ -31,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(""),
@@ -50,7 +54,7 @@ var testPayload = &loadbalancer.CreateLoadBalancerPayload{
Networks: &[]loadbalancer.Network{
{
NetworkId: utils.Ptr(""),
- Role: utils.Ptr(""),
+ Role: loadbalancer.NetworkRole("").Ptr(),
},
},
Options: &loadbalancer.LoadBalancerOptions{
@@ -98,7 +102,8 @@ var testPayload = &loadbalancer.CreateLoadBalancerPayload{
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
- projectIdFlag: testProjectId,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
payloadFlag: `{
"externalAddress": "",
"listeners": [
@@ -180,6 +185,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
Payload: testPayload,
@@ -191,7 +197,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiCreateLoadBalancerRequest)) loadbalancer.ApiCreateLoadBalancerRequest {
- request := testClient.CreateLoadBalancer(testCtx, testProjectId)
+ request := testClient.CreateLoadBalancer(testCtx, testProjectId, testRegion)
request = request.CreateLoadBalancerPayload(*testPayload)
request = request.XRequestID(testRequestId)
for _, mod := range mods {
@@ -203,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
@@ -226,21 +233,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,
},
@@ -270,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 ff3f50e80..e1c3a33af 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,9 +66,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting load balancer")
- _, err = wait.DeleteLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, model.LoadBalancerName).WaitWithContext(ctx)
+ _, err = wait.DeleteLoadBalancerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.LoadBalancerName).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for load balancer deletion: %w", err)
}
@@ -79,7 +79,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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,19 +99,11 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiDeleteLoadBalancerRequest {
- req := apiClient.DeleteLoadBalancer(ctx, model.ProjectId, model.LoadBalancerName)
+ req := apiClient.DeleteLoadBalancer(ctx, model.ProjectId, model.Region, model.LoadBalancerName)
return req
}
diff --git a/internal/cmd/load-balancer/delete/delete_test.go b/internal/cmd/load-balancer/delete/delete_test.go
index eac37e0fa..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"
@@ -13,7 +13,9 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
-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,
},
LoadBalancerName: testLoadBalancerName,
@@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiDeleteLoadBalancerRequest)) loadbalancer.ApiDeleteLoadBalancerRequest {
- request := testClient.DeleteLoadBalancer(testCtx, testProjectId, testLoadBalancerName)
+ request := testClient.DeleteLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancerName)
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,
},
@@ -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)
})
}
}
diff --git a/internal/cmd/load-balancer/describe/describe.go b/internal/cmd/load-balancer/describe/describe.go
index d5cf4dec9..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"
@@ -14,6 +14,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
@@ -28,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",
@@ -44,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
}
@@ -61,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
@@ -80,66 +81,41 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiGetLoadBalancerRequest {
- req := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.LoadBalancerName)
+ req := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, model.LoadBalancerName)
return req
}
func outputResult(p *print.Printer, outputFormat string, loadBalancer *loadbalancer.LoadBalancer) error {
- switch outputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(loadBalancer, "", " ")
- if err != nil {
- return fmt.Errorf("marshal load balancer: %w", err)
+ if loadBalancer == nil {
+ return fmt.Errorf("loadbalancer response is empty")
+ }
+
+ 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))
+ 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 := renderLoadBalancer(loadBalancer)
-
- if loadBalancer.Listeners != nil {
- content += renderListeners(*loadBalancer.Listeners)
- }
-
- if loadBalancer.TargetPools != nil {
- content += renderTargetPools(*loadBalancer.TargetPools)
- }
-
- err := p.PagerDisplay(content)
- if err != nil {
- return fmt.Errorf("display output: %w", err)
- }
-
- return nil
-}
-
-func renderLoadBalancer(loadBalancer *loadbalancer.LoadBalancer) string {
+func buildLoadBalancerTable(loadBalancer *loadbalancer.LoadBalancer) tables.Table {
acl := []string{}
privateAccessOnly := false
if loadBalancer.Options != nil {
@@ -158,10 +134,7 @@ func renderLoadBalancer(loadBalancer *loadbalancer.LoadBalancer) string {
networkId = *networks[0].NetworkId
}
- externalAdress := "-"
- if loadBalancer.ExternalAddress != nil {
- externalAdress = *loadBalancer.ExternalAddress
- }
+ externalAddress := utils.PtrStringDefault(loadBalancer.ExternalAddress, "-")
errorDescriptions := []string{}
if loadBalancer.Errors != nil && len((*loadBalancer.Errors)) > 0 {
@@ -172,9 +145,9 @@ func renderLoadBalancer(loadBalancer *loadbalancer.LoadBalancer) string {
table := tables.NewTable()
table.SetTitle("Load Balancer")
- table.AddRow("NAME", *loadBalancer.Name)
+ table.AddRow("NAME", utils.PtrString(loadBalancer.Name))
table.AddSeparator()
- table.AddRow("STATE", *loadBalancer.Status)
+ table.AddRow("STATE", utils.PtrString(loadBalancer.Status))
table.AddSeparator()
if len(errorDescriptions) > 0 {
table.AddRow("ERROR DESCRIPTIONS", strings.Join(errorDescriptions, "\n"))
@@ -182,31 +155,36 @@ func renderLoadBalancer(loadBalancer *loadbalancer.LoadBalancer) string {
}
table.AddRow("PRIVATE ACCESS ONLY", privateAccessOnly)
table.AddSeparator()
- table.AddRow("ATTACHED PUBLIC IP", externalAdress)
+ table.AddRow("ATTACHED PUBLIC IP", externalAddress)
table.AddSeparator()
table.AddRow("ATTACHED NETWORK ID", networkId)
table.AddSeparator()
table.AddRow("ACL", acl)
- return table.Render()
+ return table
}
-func renderListeners(listeners []loadbalancer.Listener) string {
+func buildListenersTable(listeners []loadbalancer.Listener) tables.Table {
table := tables.NewTable()
table.SetTitle("Listeners")
table.SetHeader("NAME", "PORT", "PROTOCOL", "TARGET POOL")
for i := range listeners {
listener := listeners[i]
- table.AddRow(*listener.Name, *listener.Port, *listener.Protocol, *listener.TargetPool)
+ table.AddRow(
+ utils.PtrString(listener.Name),
+ utils.PtrString(listener.Port),
+ utils.PtrString(listener.Protocol),
+ utils.PtrString(listener.TargetPool),
+ )
}
- return table.Render()
+ return table
}
-func renderTargetPools(targetPools []loadbalancer.TargetPool) string {
+func buildTargetPoolsTable(targetPools []loadbalancer.TargetPool) tables.Table {
table := tables.NewTable()
table.SetTitle("Target Pools")
table.SetHeader("NAME", "PORT", "TARGETS")
for _, targetPool := range targetPools {
- table.AddRow(*targetPool.Name, *targetPool.TargetPort, len(*targetPool.Targets))
+ table.AddRow(utils.PtrString(targetPool.Name), utils.PtrString(targetPool.TargetPort), len(*targetPool.Targets))
}
- return table.Render()
+ return table
}
diff --git a/internal/cmd/load-balancer/describe/describe_test.go b/internal/cmd/load-balancer/describe/describe_test.go
index 33960ecca..5dcbfe446 100644
--- a/internal/cmd/load-balancer/describe/describe_test.go
+++ b/internal/cmd/load-balancer/describe/describe_test.go
@@ -4,23 +4,27 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+ testloadBalancerName = "loadBalancer"
+)
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &loadbalancer.APIClient{}
var testProjectId = uuid.NewString()
-var testloadBalancerName = "loadBalancer"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -34,7 +38,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 +51,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
LoadBalancerName: testloadBalancerName,
@@ -57,7 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiGetLoadBalancerRequest)) loadbalancer.ApiGetLoadBalancerRequest {
- request := testClient.GetLoadBalancer(testCtx, testProjectId, testloadBalancerName)
+ request := testClient.GetLoadBalancer(testCtx, testProjectId, testRegion, testloadBalancerName)
for _, mod := range mods {
mod(&request)
}
@@ -101,7 +107,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 +115,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 +123,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 +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 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,3 +165,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ loadBalancer *loadbalancer.LoadBalancer
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only loadbalancer as argument",
+ args: args{
+ loadBalancer: &loadbalancer.LoadBalancer{},
+ },
+ 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.loadBalancer); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 4d560fc42..1149ae311 100644
--- a/internal/cmd/load-balancer/generate-payload/generate_payload.go
+++ b/internal/cmd/load-balancer/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"
@@ -34,7 +36,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 +53,7 @@ var (
defaultPayloadNetwork = &loadbalancer.Network{
NetworkId: utils.Ptr(""),
- Role: utils.Ptr(""),
+ Role: loadbalancer.NetworkRole("").Ptr(),
}
defaultPayloadTargetPool = &loadbalancer.TargetPool{
@@ -109,7 +111,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",
@@ -135,20 +137,20 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +170,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 +182,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,31 +197,26 @@ 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 *loadbalancer.APIClient) loadbalancer.ApiGetLoadBalancerRequest {
- req := apiClient.GetLoadBalancer(ctx, model.ProjectId, *model.LoadBalancerName)
+ req := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, *model.LoadBalancerName)
return req
}
func outputCreateResult(p *print.Printer, filePath *string, payload *loadbalancer.CreateLoadBalancerPayload) error {
+ if payload == nil {
+ return fmt.Errorf("loadbalancer payload is empty")
+ }
payloadBytes, err := json.MarshalIndent(*payload, "", " ")
if err != nil {
return fmt.Errorf("marshal create load balancer payload: %w", err)
}
if filePath != nil {
- err = fileutils.WriteToFile(*filePath, string(payloadBytes))
+ err = fileutils.WriteToFile(utils.PtrString(filePath), string(payloadBytes))
if err != nil {
return fmt.Errorf("write create load balancer payload to the file: %w", err)
}
@@ -231,13 +228,16 @@ func outputCreateResult(p *print.Printer, filePath *string, payload *loadbalance
}
func outputUpdateResult(p *print.Printer, filePath *string, payload *loadbalancer.UpdateLoadBalancerPayload) error {
+ if payload == nil {
+ return fmt.Errorf("loadbalancer payload is empty")
+ }
payloadBytes, err := json.MarshalIndent(*payload, "", " ")
if err != nil {
return fmt.Errorf("marshal update load balancer payload: %w", err)
}
if filePath != nil {
- err = fileutils.WriteToFile(*filePath, string(payloadBytes))
+ err = fileutils.WriteToFile(utils.PtrString(filePath), string(payloadBytes))
if err != nil {
return fmt.Errorf("write update load balancer payload to the file: %w", err)
}
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 b8f48fc69..ca20bc950 100644
--- a/internal/cmd/load-balancer/generate-payload/generate_payload_test.go
+++ b/internal/cmd/load-balancer/generate-payload/generate_payload_test.go
@@ -4,18 +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-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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+)
type testCtxKey struct{}
@@ -30,9 +33,10 @@ const (
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
- projectIdFlag: testProjectId,
- loadBalancerNameFlag: testLoadBalancerName,
- filePathFlag: testFilePath,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ loadBalancerNameFlag: testLoadBalancerName,
+ filePathFlag: testFilePath,
}
for _, mod := range mods {
mod(flagValues)
@@ -44,6 +48,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
LoadBalancerName: utils.Ptr(testLoadBalancerName),
@@ -56,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiGetLoadBalancerRequest)) loadbalancer.ApiGetLoadBalancerRequest {
- request := testClient.GetLoadBalancer(testCtx, testProjectId, testLoadBalancerName)
+ request := testClient.GetLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancerName)
for _, mod := range mods {
mod(&request)
}
@@ -66,6 +71,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
@@ -107,21 +113,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,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.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)
})
}
}
@@ -223,7 +182,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{
{
@@ -241,7 +200,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{
{
@@ -262,7 +221,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{
{
@@ -280,7 +239,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{
{
@@ -310,3 +269,71 @@ func TestModifyListeners(t *testing.T) {
})
}
}
+
+func TestOutputCreateResult(t *testing.T) {
+ type args struct {
+ filePath *string
+ payload *loadbalancer.CreateLoadBalancerPayload
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only loadbalancer payload as argument",
+ args: args{
+ payload: &loadbalancer.CreateLoadBalancerPayload{},
+ },
+ 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 := outputCreateResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr {
+ t.Errorf("outputCreateResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestOutputUpdateResult(t *testing.T) {
+ type args struct {
+ filePath *string
+ payload *loadbalancer.UpdateLoadBalancerPayload
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only loadbalancer payload as argument",
+ args: args{
+ payload: &loadbalancer.UpdateLoadBalancerPayload{},
+ },
+ 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 := outputUpdateResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr {
+ t.Errorf("outputUpdateResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/load-balancer/list/list.go b/internal/cmd/load-balancer/list/list.go
index a82a0384d..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"
@@ -15,6 +15,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/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/loadbalancer"
@@ -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 Load Balancers",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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.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
}
@@ -82,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)
},
}
@@ -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,51 +114,37 @@ 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 *loadbalancer.APIClient) loadbalancer.ApiListLoadBalancersRequest {
- req := apiClient.ListLoadBalancers(ctx, model.ProjectId)
+ req := apiClient.ListLoadBalancers(ctx, model.ProjectId, model.Region)
return req
}
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))
- 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 {
l := loadBalancers[i]
- externalAdress := "-"
- if l.ExternalAddress != nil {
- externalAdress = *l.ExternalAddress
+ var numListeners, numTargetPools int
+ if l.Listeners != nil {
+ numListeners = len(*l.Listeners)
+ }
+ if l.TargetPools != nil {
+ numTargetPools = len(*l.TargetPools)
}
- table.AddRow(*l.Name, *l.Status, externalAdress, len(*l.Listeners), len(*l.TargetPools))
+
+ externalAddress := utils.PtrStringDefault(l.ExternalAddress, "-")
+ table.AddRow(
+ utils.PtrString(l.Name),
+ utils.PtrString(l.Status),
+ externalAddress,
+ numListeners,
+ numTargetPools,
+ )
}
err := table.Display(p)
if err != nil {
@@ -165,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 0f035066d..4d6decfa3 100644
--- a/internal/cmd/load-balancer/list/list_test.go
+++ b/internal/cmd/load-balancer/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-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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+)
type testCtxKey struct{}
@@ -24,8 +28,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)
@@ -37,6 +42,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)),
@@ -48,7 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiListLoadBalancersRequest)) loadbalancer.ApiListLoadBalancersRequest {
- request := testClient.ListLoadBalancers(testCtx, testProjectId)
+ request := testClient.ListLoadBalancers(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -58,6 +64,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
@@ -76,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,
},
@@ -112,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 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,3 +151,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ loadBalancers []loadbalancer.LoadBalancer
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty loadbalancers slice",
+ args: args{
+ loadBalancers: []loadbalancer.LoadBalancer{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty loadbalancer in loadbalancers slice",
+ args: args{
+ loadBalancers: []loadbalancer.LoadBalancer{{}},
+ },
+ 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.loadBalancers); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 b1213eefd..edb3f447e 100644
--- a/internal/cmd/load-balancer/observability-credentials/add/add.go
+++ b/internal/cmd/load-balancer/observability-credentials/add/add.go
@@ -2,10 +2,10 @@ 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"
@@ -34,11 +34,11 @@ 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",
- Long: "Adds existing observability credentials (username and password) to Load Balancer. The credentials can be for Argus or another monitoring tool.",
+ Long: "Adds existing observability credentials (username and password) to Load Balancer. The credentials can be for Observability or another monitoring tool.",
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
@@ -50,38 +50,36 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +89,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("add Load Balancer observability credentials: %w", err)
}
- return outputResult(p, model, projectLabel, resp)
+ return outputResult(params.Printer, model.OutputFormat, projectLabel, resp)
},
}
configureFlags(cmd)
@@ -107,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,20 +118,12 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiCreateCredentialsRequest {
- req := apiClient.CreateCredentials(ctx, model.ProjectId)
+ req := apiClient.CreateCredentials(ctx, model.ProjectId, model.Region)
req = req.XRequestID(uuid.NewString())
req = req.CreateCredentialsPayload(loadbalancer.CreateCredentialsPayload{
@@ -144,30 +134,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalance
return req
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *loadbalancer.CreateCredentialsResponse) error {
- if resp.Credential == nil {
+func outputResult(p *print.Printer, outputFormat, projectLabel string, resp *loadbalancer.CreateCredentialsResponse) error {
+ if resp == nil || resp.Credential == nil {
return fmt.Errorf("nil observability credentials response")
}
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal Load Balancer observability credentials: %w", err)
- }
- p.Outputln(string(details))
-
+ 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
- default:
- p.Outputf("Added Load Balancer observability credentials on project %q. Credentials reference: %q\n", projectLabel, *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 85fc6b7d6..f36d7e00c 100644
--- a/internal/cmd/load-balancer/observability-credentials/add/add_test.go
+++ b/internal/cmd/load-balancer/observability-credentials/add/add_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-sdk-go/services/loadbalancer"
+ "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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+)
type testCtxKey struct{}
@@ -24,10 +28,11 @@ var testProjectId = uuid.NewString()
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
- projectIdFlag: testProjectId,
- displayNameFlag: "name",
- usernameFlag: "username",
- passwordFlag: "pwd",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ displayNameFlag: "name",
+ usernameFlag: "username",
+ passwordFlag: "pwd",
}
for _, mod := range mods {
mod(flagValues)
@@ -39,6 +44,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
DisplayName: utils.Ptr("name"),
@@ -52,7 +58,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiCreateCredentialsRequest)) loadbalancer.ApiCreateCredentialsRequest {
- request := testClient.CreateCredentials(testCtx, testProjectId)
+ request := testClient.CreateCredentials(testCtx, testProjectId, testRegion)
request = request.CreateCredentialsPayload(loadbalancer.CreateCredentialsPayload{
DisplayName: utils.Ptr("name"),
Username: utils.Ptr("username"),
@@ -67,6 +73,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
@@ -85,21 +92,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 +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 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)
})
}
}
@@ -185,7 +153,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)
@@ -193,3 +161,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ resp *loadbalancer.CreateCredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "credentials response with empty Credential",
+ args: args{
+ resp: &loadbalancer.CreateCredentialsResponse{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "only credentials response as argument",
+ args: args{
+ resp: &loadbalancer.CreateCredentialsResponse{Credential: &loadbalancer.CredentialsResponse{}},
+ },
+ 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.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 2192b4591..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",
@@ -34,20 +36,20 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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
}
@@ -59,32 +61,30 @@ func NewCmd(p *print.Printer) *cobra.Command {
var credentials []loadbalancer.CredentialsResponse
if resp.Credentials != nil && len(*resp.Credentials) > 0 {
- credentials, err = utils.FilterCredentials(ctx, apiClient, *resp.Credentials, model.ProjectId, utils.OP_FILTER_UNUSED)
+ credentials, err = utils.FilterCredentials(ctx, apiClient, *resp.Credentials, model.ProjectId, model.Region, utils.OP_FILTER_UNUSED)
if err != nil {
return fmt.Errorf("filter Load Balancer observability credentials: %w", err)
}
}
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,24 +117,16 @@ 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 buildDeleteCredentialRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient, credentialsRef string) loadbalancer.ApiDeleteCredentialsRequest {
- req := apiClient.DeleteCredentials(ctx, model.ProjectId, credentialsRef)
+ req := apiClient.DeleteCredentials(ctx, model.ProjectId, model.Region, credentialsRef)
return req
}
func buildListCredentialsRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiListCredentialsRequest {
- req := apiClient.ListCredentials(ctx, model.ProjectId)
+ req := apiClient.ListCredentials(ctx, model.ProjectId, model.Region)
return req
}
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 ffa7a07e7..338dcaa08 100644
--- a/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_test.go
+++ b/internal/cmd/load-balancer/observability-credentials/cleanup/cleanup_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-sdk-go/services/loadbalancer"
"github.com/google/go-cmp/cmp"
@@ -13,9 +13,10 @@ import (
"github.com/google/uuid"
)
-const testCredentialsRef = "credentials-1"
-
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+ testCredentialsRef = "credentials-1"
+)
type testCtxKey struct{}
@@ -25,7 +26,8 @@ var 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,
}
for _, mod := range mods {
mod(flagValues)
@@ -37,6 +39,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
}
@@ -47,7 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureDeleteCredentialRequest(mods ...func(request *loadbalancer.ApiDeleteCredentialsRequest)) loadbalancer.ApiDeleteCredentialsRequest {
- request := testClient.DeleteCredentials(testCtx, testProjectId, testCredentialsRef)
+ request := testClient.DeleteCredentials(testCtx, testProjectId, testRegion, testCredentialsRef)
for _, mod := range mods {
mod(&request)
}
@@ -55,7 +58,7 @@ func fixtureDeleteCredentialRequest(mods ...func(request *loadbalancer.ApiDelete
}
func fixtureListCredentialsRequest(mods ...func(request *loadbalancer.ApiListCredentialsRequest)) loadbalancer.ApiListCredentialsRequest {
- request := testClient.ListCredentials(testCtx, testProjectId)
+ request := testClient.ListCredentials(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -90,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,
},
@@ -112,54 +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)
- err := globalflags.Configure(cmd.Flags())
- if err != nil {
- t.Fatalf("configure global flags: %v", err)
- }
-
- for flag, value := range tt.flagValues {
- err := 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 a3c6eb87d..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.CredentialsRef)
+ 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,19 +97,11 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiDeleteCredentialsRequest {
- req := apiClient.DeleteCredentials(ctx, model.ProjectId, model.CredentialsRef)
+ req := apiClient.DeleteCredentials(ctx, model.ProjectId, model.Region, model.CredentialsRef)
return req
}
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 629b93f5c..c53114075 100644
--- a/internal/cmd/load-balancer/observability-credentials/delete/delete_test.go
+++ b/internal/cmd/load-balancer/observability-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/stackitcloud/stackit-sdk-go/services/loadbalancer"
"github.com/google/go-cmp/cmp"
@@ -13,7 +13,10 @@ import (
"github.com/google/uuid"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+ testCredentialsRef = "credentials-xxx"
+)
type testCtxKey struct{}
@@ -21,8 +24,6 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &loadbalancer.APIClient{}
var testProjectId = uuid.NewString()
-const testCredentialsRef = "credentials-xxx"
-
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testCredentialsRef,
@@ -35,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 +49,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
CredentialsRef: testCredentialsRef,
@@ -58,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiDeleteCredentialsRequest)) loadbalancer.ApiDeleteCredentialsRequest {
- request := testClient.DeleteCredentials(testCtx, testProjectId, testCredentialsRef)
+ request := testClient.DeleteCredentials(testCtx, testProjectId, testRegion, testCredentialsRef)
for _, mod := range mods {
mod(&request)
}
@@ -102,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,
},
@@ -110,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,
},
@@ -118,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,
},
@@ -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 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 27d27caf8..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"
@@ -13,6 +13,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
@@ -27,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",
@@ -40,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
}
@@ -58,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
@@ -77,48 +78,27 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiGetCredentialsRequest {
- req := apiClient.GetCredentials(ctx, model.ProjectId, model.CredentialsRef)
+ req := apiClient.GetCredentials(ctx, model.ProjectId, model.Region, model.CredentialsRef)
return req
}
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)
+ return p.OutputResult(outputFormat, credentials, func() error {
+ if credentials == nil || credentials.Credential == nil {
+ return fmt.Errorf("credentials response is empty")
}
- p.Outputln(string(details))
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Load Balancer observability credentials: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
table := tables.NewTable()
- table.AddRow("REFERENCE", *credentials.Credential.CredentialsRef)
+ table.AddRow("REFERENCE", utils.PtrString(credentials.Credential.CredentialsRef))
table.AddSeparator()
- table.AddRow("DISPLAY NAME", *credentials.Credential.DisplayName)
+ table.AddRow("DISPLAY NAME", utils.PtrString(credentials.Credential.DisplayName))
table.AddSeparator()
- table.AddRow("USERNAME", *credentials.Credential.Username)
+ table.AddRow("USERNAME", utils.PtrString(credentials.Credential.Username))
table.AddSeparator()
err := table.Display(p)
if err != nil {
@@ -126,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 d916923fd..1060baeab 100644
--- a/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go
+++ b/internal/cmd/load-balancer/observability-credentials/describe/describe_test.go
@@ -4,16 +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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+ testCredentialsRef = "credentials-test"
+)
type testCtxKey struct{}
@@ -21,8 +26,6 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &loadbalancer.APIClient{}
var testProjectId = uuid.NewString()
-const testCredentialsRef = "credentials-test"
-
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testCredentialsRef,
@@ -35,7 +38,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 +51,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
CredentialsRef: testCredentialsRef,
@@ -58,7 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiGetCredentialsRequest)) loadbalancer.ApiGetCredentialsRequest {
- request := testClient.GetCredentials(testCtx, testProjectId, testCredentialsRef)
+ request := testClient.GetCredentials(testCtx, testProjectId, testRegion, testCredentialsRef)
for _, mod := range mods {
mod(&request)
}
@@ -102,7 +107,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 +115,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 +123,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,
},
@@ -132,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)
})
}
}
@@ -211,3 +169,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials *loadbalancer.GetCredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "credentials response with empty Credential",
+ args: args{
+ credentials: &loadbalancer.GetCredentialsResponse{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "only credentials as argument",
+ args: args{
+ credentials: &loadbalancer.GetCredentialsResponse{Credential: &loadbalancer.CredentialsResponse{}},
+ },
+ 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.credentials); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 226d9f06a..1b58291a6 100644
--- a/internal/cmd/load-balancer/observability-credentials/list/list.go
+++ b/internal/cmd/load-balancer/observability-credentials/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -14,10 +15,9 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/client"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils"
+ lbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
@@ -35,7 +35,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",
@@ -60,20 +60,20 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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
}
@@ -92,7 +92,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
if err != nil {
return err
}
- credentials, err = utils.FilterCredentials(ctx, apiClient, credentials, model.ProjectId, filterOp)
+ credentials, err = lbUtils.FilterCredentials(ctx, apiClient, credentials, model.ProjectId, model.Region, filterOp)
if err != nil {
return fmt.Errorf("filter credentials: %w", err)
}
@@ -105,7 +105,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 +113,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 +128,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,47 +149,22 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiListCredentialsRequest {
- req := apiClient.ListCredentials(ctx, model.ProjectId)
+ req := apiClient.ListCredentials(ctx, model.ProjectId, model.Region)
return req
}
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))
- 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 {
c := credentials[i]
- table.AddRow(*c.CredentialsRef, *c.DisplayName, *c.Username)
+ table.AddRow(utils.PtrString(c.CredentialsRef), utils.PtrString(c.DisplayName), utils.PtrString(c.Username))
}
err := table.Display(p)
if err != nil {
@@ -197,7 +172,7 @@ func outputResult(p *print.Printer, outputFormat string, credentials []loadbalan
}
return nil
- }
+ })
}
func getFilterOp(used, unused bool) (int, error) {
@@ -207,12 +182,12 @@ func getFilterOp(used, unused bool) (int, error) {
}
if !used && !unused {
- return utils.OP_FILTER_NOP, nil
+ return lbUtils.OP_FILTER_NOP, nil
}
if used {
- return utils.OP_FILTER_USED, nil
+ return lbUtils.OP_FILTER_USED, nil
}
- return utils.OP_FILTER_UNUSED, nil
+ return lbUtils.OP_FILTER_UNUSED, nil
}
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 736adf134..85b3650fa 100644
--- a/internal/cmd/load-balancer/observability-credentials/list/list_test.go
+++ b/internal/cmd/load-balancer/observability-credentials/list/list_test.go
@@ -4,18 +4,22 @@ import (
"context"
"testing"
- "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/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-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"
)
-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 *loadbalancer.ApiListCredentialsRequest)) loadbalancer.ApiListCredentialsRequest {
- request := testClient.ListCredentials(testCtx, testProjectId)
+ request := testClient.ListCredentials(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -59,6 +65,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
@@ -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,
},
@@ -141,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.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)
})
}
}
@@ -269,3 +229,37 @@ func TestGetFilterOp(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials []loadbalancer.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty credentials response in loadbalancers slice",
+ args: args{
+ credentials: []loadbalancer.CredentialsResponse{{}},
+ },
+ 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.credentials); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 9ad8500d1..7abc80f62 100644
--- a/internal/cmd/load-balancer/observability-credentials/observability-credentials.go
+++ b/internal/cmd/load-balancer/observability-credentials/observability-credentials.go
@@ -8,30 +8,30 @@ 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",
- Long: `Provides functionality for Load Balancer observability credentials. These commands can be used to store and update existing credentials, which are valid to be used for Load Balancer observability. This means, e.g. when using Argus, first of all these credentials must be created for that Argus instance (by using "stackit argus credentials create") and then can be managed for a Load Balancer by using the commands in this group.`,
+ Long: `Provides functionality for Load Balancer observability credentials. These commands can be used to store and update existing credentials, which are valid to be used for Load Balancer observability. This means, e.g. when using Observability, first of all these credentials must be created for that Observability instance (by using "stackit observability credentials create") and then can be managed for a Load Balancer by using the commands in this group.`,
Args: args.NoArgs,
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 0f395139f..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"
@@ -27,6 +29,16 @@ const (
credentialsRefArg = "CREDENTIALS_REF" //nolint:gosec // linter false positive
)
+// enforce implementation of interfaces
+var (
+ _ loadBalancerClient = &loadbalancer.APIClient{}
+)
+
+type loadBalancerClient interface {
+ UpdateCredentials(ctx context.Context, projectId, region, credentialsRef string) loadbalancer.ApiUpdateCredentialsRequest
+ GetCredentialsExecute(ctx context.Context, projectId, region, credentialsRef string) (*loadbalancer.GetCredentialsResponse, error)
+}
+
type inputModel struct {
*globalflags.GlobalFlagModel
CredentialsRef string
@@ -35,11 +47,11 @@ type inputModel struct {
Password *string
}
-func NewCmd(p *print.Printer) *cobra.Command {
+func NewCmd(params *types.CmdParams) *cobra.Command {
cmd := &cobra.Command{
- Use: "update",
+ Use: fmt.Sprintf("update %s", credentialsRefArg),
Short: "Updates observability credentials for Load Balancer",
- Long: "Updates existing observability credentials (username and password) for Load Balancer. The credentials can be for Argus or another monitoring tool.",
+ Long: "Updates existing observability credentials (username and password) for Load Balancer. The credentials can be for Observability or another monitoring tool.",
Args: args.SingleArg(credentialsRefArg, nil),
Example: examples.Build(
examples.NewExample(
@@ -51,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.CredentialsRef)
+ 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
@@ -102,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
},
}
@@ -137,15 +147,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
}, nil
}
-type loadBalancerClient interface {
- UpdateCredentials(ctx context.Context, instanceId, projectId string) loadbalancer.ApiUpdateCredentialsRequest
- GetCredentialsExecute(ctx context.Context, instanceId, projectId string) (*loadbalancer.GetCredentialsResponse, error)
-}
-
func buildRequest(ctx context.Context, model *inputModel, apiClient loadBalancerClient) (loadbalancer.ApiUpdateCredentialsRequest, error) {
- req := apiClient.UpdateCredentials(ctx, model.ProjectId, model.CredentialsRef)
+ req := apiClient.UpdateCredentials(ctx, model.ProjectId, model.Region, model.CredentialsRef)
- currentCredentials, err := apiClient.GetCredentialsExecute(ctx, model.ProjectId, model.CredentialsRef)
+ currentCredentials, err := apiClient.GetCredentialsExecute(ctx, model.ProjectId, model.Region, model.CredentialsRef)
if err != nil {
return req, fmt.Errorf("get Load Balancer observability credentials: %w", err)
}
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 6b30da82b..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"
@@ -15,33 +15,35 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+ testCredentialsRef = "credentials-test"
+)
type testCtxKey struct{}
-var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &loadbalancer.APIClient{}
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &loadbalancer.APIClient{}
+ testProjectId = uuid.NewString()
+)
type loadBalancerClientMocked struct {
getCredentialsError bool
getCredentialsResponse *loadbalancer.GetCredentialsResponse
}
-func (c *loadBalancerClientMocked) UpdateCredentials(ctx context.Context, projectId, credentialsRef string) loadbalancer.ApiUpdateCredentialsRequest {
- return testClient.UpdateCredentials(ctx, projectId, credentialsRef)
+func (c *loadBalancerClientMocked) UpdateCredentials(ctx context.Context, projectId, region, credentialsRef string) loadbalancer.ApiUpdateCredentialsRequest {
+ return testClient.UpdateCredentials(ctx, projectId, region, credentialsRef)
}
-func (c *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) {
+func (c *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _, _ string) (*loadbalancer.GetCredentialsResponse, error) {
if c.getCredentialsError {
return nil, fmt.Errorf("get credentials failed")
}
return c.getCredentialsResponse, nil
}
-var testProjectId = uuid.NewString()
-
-const testCredentialsRef = "credentials-test"
-
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testCredentialsRef,
@@ -54,10 +56,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,
- displayNameFlag: "name",
- usernameFlag: "username",
- passwordFlag: "pwd",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ displayNameFlag: "name",
+ usernameFlag: "username",
+ passwordFlag: "pwd",
}
for _, mod := range mods {
mod(flagValues)
@@ -69,6 +72,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
DisplayName: utils.Ptr("name"),
@@ -83,7 +87,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateCredentialsRequest)) loadbalancer.ApiUpdateCredentialsRequest {
- request := testClient.UpdateCredentials(testCtx, testProjectId, testCredentialsRef)
+ request := testClient.UpdateCredentials(testCtx, testProjectId, testRegion, testCredentialsRef)
request = request.UpdateCredentialsPayload(loadbalancer.UpdateCredentialsPayload{
DisplayName: utils.Ptr("name"),
Username: utils.Ptr("username"),
@@ -145,7 +149,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,
},
@@ -153,7 +157,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,
},
@@ -161,7 +165,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,
},
@@ -175,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 593e117b7..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",
@@ -35,12 +35,12 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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,27 +68,21 @@ 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 *loadbalancer.APIClient) loadbalancer.ApiGetQuotaRequest {
- req := apiClient.GetQuota(ctx, model.ProjectId)
+ req := apiClient.GetQuota(ctx, model.ProjectId, model.Region)
return req
}
func outputResult(p *print.Printer, outputFormat string, quota *loadbalancer.GetQuotaResponse) error {
- switch outputFormat {
- case print.PrettyOutputFormat:
+ if quota == nil {
+ return fmt.Errorf("quota response is empty")
+ }
+ return p.OutputResult(outputFormat, quota, func() error {
maxLoadBalancers := "Unlimited"
if quota.MaxLoadBalancers != nil && *quota.MaxLoadBalancers != -1 {
maxLoadBalancers = strconv.FormatInt(*quota.MaxLoadBalancers, 10)
@@ -97,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))
- 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 47bc747c9..1a3ccae0a 100644
--- a/internal/cmd/load-balancer/quota/quota_test.go
+++ b/internal/cmd/load-balancer/quota/quota_test.go
@@ -4,16 +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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+)
type testCtxKey struct{}
@@ -23,7 +27,8 @@ var 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,
}
for _, mod := range mods {
mod(flagValues)
@@ -35,6 +40,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
}
@@ -45,7 +51,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiGetQuotaRequest)) loadbalancer.ApiGetQuotaRequest {
- request := testClient.GetQuota(testCtx, testProjectId)
+ request := testClient.GetQuota(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -55,6 +61,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
@@ -68,21 +75,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,
},
@@ -90,46 +97,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)
})
}
}
@@ -163,3 +131,40 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ quota *loadbalancer.GetQuotaResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ outputFormat: print.PrettyOutputFormat, // default output format is json
+ },
+ wantErr: true,
+ },
+ {
+ name: "only quota as argument",
+ args: args{
+ outputFormat: print.PrettyOutputFormat, // default output format is json
+ quota: &loadbalancer.GetQuotaResponse{},
+ },
+ 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.quota); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 13d816e9f..4cc41d4ef 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,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 {
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 +51,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 +78,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,22 +111,14 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient utils.LoadBalancerClient) (loadbalancer.ApiUpdateTargetPoolRequest, error) {
- req := apiClient.UpdateTargetPool(ctx, model.ProjectId, model.LBName, model.TargetPoolName)
+ req := apiClient.UpdateTargetPool(ctx, model.ProjectId, model.Region, model.LBName, model.TargetPoolName)
- targetPool, err := utils.GetLoadBalancerTargetPool(ctx, apiClient, model.ProjectId, model.LBName, model.TargetPoolName)
+ targetPool, err := utils.GetLoadBalancerTargetPool(ctx, apiClient, model.ProjectId, model.Region, model.LBName, model.TargetPoolName)
if err != nil {
return req, fmt.Errorf("get load balancer target pool: %w", err)
}
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 8120dc497..16ebc3096 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,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/google/uuid"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var (
@@ -26,6 +26,7 @@ var (
)
const (
+ testRegion = "eu02"
testLBName = "my-load-balancer"
testTargetPoolName = "target-pool-1"
testTargetName = "my-target"
@@ -39,25 +40,25 @@ type loadBalancerClientMocked struct {
getLoadBalancerResp *loadbalancer.LoadBalancer
}
-func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) {
+func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _, _ string) (*loadbalancer.GetCredentialsResponse, error) {
if m.getCredentialsFails {
return nil, fmt.Errorf("could not get credentials")
}
return m.getCredentialsResp, nil
}
-func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _ string) (*loadbalancer.LoadBalancer, error) {
+func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _, _ string) (*loadbalancer.LoadBalancer, error) {
if m.getLoadBalancerFails {
return nil, fmt.Errorf("could not get load balancer")
}
return m.getLoadBalancerResp, nil
}
-func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, projectId, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest {
- return testClient.UpdateTargetPool(ctx, projectId, loadBalancerName, targetPoolName)
+func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, projectId, region, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest {
+ return testClient.UpdateTargetPool(ctx, projectId, region, loadBalancerName, targetPoolName)
}
-func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _ string) (*loadbalancer.ListLoadBalancersResponse, error) {
+func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _, _ string) (*loadbalancer.ListLoadBalancersResponse, error) {
return nil, nil
}
@@ -73,10 +74,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,
- lbNameFlag: testLBName,
- targetNameFlag: testTargetName,
- targetPoolNameFlag: testTargetPoolName,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ lbNameFlag: testLBName,
+ targetNameFlag: testTargetName,
+ targetPoolNameFlag: testTargetPoolName,
}
for _, mod := range mods {
mod(flagValues)
@@ -88,6 +90,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
TargetPoolName: testTargetPoolName,
@@ -171,7 +174,7 @@ func fixturePayload(mods ...func(payload *loadbalancer.UpdateTargetPoolPayload))
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateTargetPoolRequest)) loadbalancer.ApiUpdateTargetPoolRequest {
- request := testClient.UpdateTargetPool(testCtx, testProjectId, testLBName, testTargetPoolName)
+ request := testClient.UpdateTargetPool(testCtx, testProjectId, testRegion, testLBName, testTargetPoolName)
request = request.UpdateTargetPoolPayload(*fixturePayload())
for _, mod := range mods {
mod(&request)
@@ -204,7 +207,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,
},
@@ -212,7 +215,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,
},
@@ -220,7 +223,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,
},
@@ -259,7 +262,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 {
@@ -336,7 +339,7 @@ func TestBuildRequest(t *testing.T) {
},
}
})
- *request = request.UpdateTargetPoolPayload(*payload)
+ *request = (*request).UpdateTargetPoolPayload(*payload)
}),
},
{
@@ -355,7 +358,7 @@ func TestBuildRequest(t *testing.T) {
},
}
})
- *request = request.UpdateTargetPoolPayload(*payload)
+ *request = (*request).UpdateTargetPoolPayload(*payload)
}),
},
{
@@ -374,7 +377,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 e3e5921fc..dadfab47d 100644
--- a/internal/cmd/load-balancer/target-pool/describe/describe.go
+++ b/internal/cmd/load-balancer/target-pool/describe/describe.go
@@ -2,12 +2,13 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,10 +16,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/load-balancer/client"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils"
+ lbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/load-balancer/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
@@ -34,7 +34,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 +50,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
}
@@ -67,14 +67,14 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("read load balancer: %w", err)
}
- targetPool := utils.FindLoadBalancerTargetPoolByName(*resp.TargetPools, model.TargetPoolName)
+ targetPool := lbUtils.FindLoadBalancerTargetPoolByName(*resp.TargetPools, model.TargetPoolName)
if targetPool == nil {
return fmt.Errorf("target pool not found")
}
- listener := utils.FindLoadBalancerListenerByTargetPool(*resp.Listeners, *targetPool.Name)
+ 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,24 +102,19 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *loadbalancer.APIClient) loadbalancer.ApiGetLoadBalancerRequest {
- req := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.LBName)
+ req := apiClient.GetLoadBalancer(ctx, model.ProjectId, model.Region, model.LBName)
return req
}
func outputResult(p *print.Printer, outputFormat string, targetPool loadbalancer.TargetPool, listener *loadbalancer.Listener) error {
+ if listener == nil {
+ return fmt.Errorf("listener response is empty")
+ }
output := struct {
*loadbalancer.TargetPool
Listener *loadbalancer.Listener `json:"attached_listener"`
@@ -128,86 +123,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))
- 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:%d, Protocol: %s)", *listener.Name, *listener.Port, *listener.Protocol)
- }
- table := tables.NewTable()
- table.AddRow("NAME", *targetPool.Name)
- table.AddSeparator()
- table.AddRow("TARGET PORT", *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 9127dc29c..7c1a7535a 100644
--- a/internal/cmd/load-balancer/target-pool/describe/describe_test.go
+++ b/internal/cmd/load-balancer/target-pool/describe/describe_test.go
@@ -4,17 +4,16 @@ 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/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-sdk-go/services/loadbalancer"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var (
@@ -24,6 +23,7 @@ var (
)
const (
+ testRegion = "eu02"
testLoadBalancerName = "my-load-balancer"
testTargetPoolName = "target-pool-1"
)
@@ -40,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,
- lbNameFlag: testLoadBalancerName,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ lbNameFlag: testLoadBalancerName,
}
for _, mod := range mods {
mod(flagValues)
@@ -53,6 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
LBName: testLoadBalancerName,
@@ -65,7 +67,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiGetLoadBalancerRequest)) loadbalancer.ApiGetLoadBalancerRequest {
- request := testClient.GetLoadBalancer(testCtx, testProjectId, testLoadBalancerName)
+ request := testClient.GetLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancerName)
for _, mod := range mods {
mod(&request)
}
@@ -97,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,
},
@@ -105,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,
},
@@ -113,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,
},
@@ -136,7 +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)
+ cmd := NewCmd(&types.CmdParams{Printer: p})
err := globalflags.Configure(cmd.Flags())
if err != nil {
@@ -217,3 +219,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ targetPool loadbalancer.TargetPool
+ listener *loadbalancer.Listener
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "listener and target pool as argument",
+ args: args{
+ targetPool: loadbalancer.TargetPool{},
+ listener: &loadbalancer.Listener{},
+ },
+ 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.targetPool, tt.args.listener); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 36a84313f..4cb053eff 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,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 {
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 +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
}
- targetLabel, err := utils.GetTargetName(ctx, apiClient, model.ProjectId, model.LBName, model.TargetPoolName, model.IP)
+ 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 +80,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,22 +111,14 @@ 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
}
func buildRequest(ctx context.Context, model *inputModel, apiClient utils.LoadBalancerClient) (loadbalancer.ApiUpdateTargetPoolRequest, error) {
- req := apiClient.UpdateTargetPool(ctx, model.ProjectId, model.LBName, model.TargetPoolName)
+ req := apiClient.UpdateTargetPool(ctx, model.ProjectId, model.Region, model.LBName, model.TargetPoolName)
- targetPool, err := utils.GetLoadBalancerTargetPool(ctx, apiClient, model.ProjectId, model.LBName, model.TargetPoolName)
+ targetPool, err := utils.GetLoadBalancerTargetPool(ctx, apiClient, model.ProjectId, model.Region, model.LBName, model.TargetPoolName)
if err != nil {
return req, fmt.Errorf("get load balancer target pool: %w", err)
}
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 ec31f13e2..e09747421 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,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/google/uuid"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var (
@@ -26,9 +26,9 @@ var (
)
const (
+ testRegion = "eu02"
testLBName = "my-load-balancer"
testTargetPoolName = "target-pool-1"
- testTargetName = "my-target"
testIP = "1.2.3.4"
)
@@ -39,25 +39,25 @@ type loadBalancerClientMocked struct {
getLoadBalancerResp *loadbalancer.LoadBalancer
}
-func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) {
+func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _, _ string) (*loadbalancer.GetCredentialsResponse, error) {
if m.getCredentialsFails {
return nil, fmt.Errorf("could not get credentials")
}
return m.getCredentialsResp, nil
}
-func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _ string) (*loadbalancer.LoadBalancer, error) {
+func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _, _ string) (*loadbalancer.LoadBalancer, error) {
if m.getLoadBalancerFails {
return nil, fmt.Errorf("could not get load balancer")
}
return m.getLoadBalancerResp, nil
}
-func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, projectId, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest {
- return testClient.UpdateTargetPool(ctx, projectId, loadBalancerName, targetPoolName)
+func (m *loadBalancerClientMocked) UpdateTargetPool(ctx context.Context, projectId, region, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest {
+ return testClient.UpdateTargetPool(ctx, projectId, region, loadBalancerName, targetPoolName)
}
-func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _ string) (*loadbalancer.ListLoadBalancersResponse, error) {
+func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _, _ string) (*loadbalancer.ListLoadBalancersResponse, error) {
return nil, nil
}
@@ -73,9 +73,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,
- lbNameFlag: testLBName,
- targetPoolNameFlag: testTargetPoolName,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ lbNameFlag: testLBName,
+ targetPoolNameFlag: testTargetPoolName,
}
for _, mod := range mods {
mod(flagValues)
@@ -87,6 +88,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
LBName: testLBName,
@@ -169,7 +171,7 @@ func fixturePayload(mods ...func(payload *loadbalancer.UpdateTargetPoolPayload))
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateTargetPoolRequest)) loadbalancer.ApiUpdateTargetPoolRequest {
- request := testClient.UpdateTargetPool(testCtx, testProjectId, testLBName, testTargetPoolName)
+ request := testClient.UpdateTargetPool(testCtx, testProjectId, testRegion, testLBName, testTargetPoolName)
request = request.UpdateTargetPoolPayload(*fixturePayload())
for _, mod := range mods {
mod(&request)
@@ -202,7 +204,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,
},
@@ -210,7 +212,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,
},
@@ -218,7 +220,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,
},
@@ -249,7 +251,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 +321,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 ba10a2d7f..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,20 +116,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 *loadbalancer.APIClient) loadbalancer.ApiUpdateLoadBalancerRequest {
- req := apiClient.UpdateLoadBalancer(ctx, model.ProjectId, model.LoadBalancerName)
+ req := apiClient.UpdateLoadBalancer(ctx, model.ProjectId, model.Region, model.LoadBalancerName)
req = req.UpdateLoadBalancerPayload(model.Payload)
return req
diff --git a/internal/cmd/load-balancer/update/update_test.go b/internal/cmd/load-balancer/update/update_test.go
index ea03f5932..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"
@@ -14,14 +14,16 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/loadbalancer"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu02"
+ testLoadBalancerName = "loadBalancer"
+)
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &loadbalancer.APIClient{}
var testProjectId = uuid.NewString()
-var testLoadBalancerName = "loadBalancer"
var testPayload = loadbalancer.UpdateLoadBalancerPayload{
ExternalAddress: utils.Ptr(""),
@@ -30,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(""),
@@ -49,7 +51,7 @@ var testPayload = loadbalancer.UpdateLoadBalancerPayload{
Networks: &[]loadbalancer.Network{
{
NetworkId: utils.Ptr(""),
- Role: utils.Ptr(""),
+ Role: loadbalancer.NetworkRole("").Ptr(),
},
},
Options: &loadbalancer.LoadBalancerOptions{
@@ -108,7 +110,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,
payloadFlag: `
{
"externalAddress": "",
@@ -193,6 +196,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
LoadBalancerName: testLoadBalancerName,
@@ -205,7 +209,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *loadbalancer.ApiUpdateLoadBalancerRequest)) loadbalancer.ApiUpdateLoadBalancerRequest {
- request := testClient.UpdateLoadBalancer(testCtx, testProjectId, testLoadBalancerName)
+ request := testClient.UpdateLoadBalancer(testCtx, testProjectId, testRegion, testLoadBalancerName)
request = request.UpdateLoadBalancerPayload(testPayload)
for _, mod := range mods {
mod(&request)
@@ -250,7 +254,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,
},
@@ -258,7 +262,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,
},
@@ -266,7 +270,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,
},
@@ -295,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 76b3b14f1..79d5577c4 100644
--- a/internal/cmd/logme/credentials/create/create.go
+++ b/internal/cmd/logme/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/spf13/cobra"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
@@ -30,7 +30,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",
@@ -46,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("create LogMe credentials: %w", err)
}
- return outputResult(p, model, instanceLabel, resp)
+ return outputResult(params.Printer, model.OutputFormat, model.ShowPassword, instanceLabel, resp)
},
}
configureFlags(cmd)
@@ -93,7 +91,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 +103,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
}
@@ -122,42 +112,31 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *logme.APICl
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *logme.CredentialsResponse) error {
- if !model.ShowPassword {
- resp.Raw.Credentials.Password = utils.Ptr("hidden")
+func outputResult(p *print.Printer, outputFormat string, showPassword bool, instanceLabel string, resp *logme.CredentialsResponse) error {
+ if resp == nil {
+ return fmt.Errorf("credentials response is empty")
}
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal LogMe credentials: %w", err)
- }
- p.Outputln(string(details))
+ if !showPassword && resp.HasRaw() && resp.Raw.Credentials != nil {
+ resp.Raw.Credentials.Password = utils.Ptr("hidden")
+ }
- return nil
- default:
- p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, *resp.Id)
+ 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
- username := *resp.Raw.Credentials.Username
- if username != "" {
- p.Outputf("Username: %s\n", *resp.Raw.Credentials.Username)
- }
- if !model.ShowPassword {
- p.Outputf("Password: \n")
- } else {
- p.Outputf("Password: %s\n", *resp.Raw.Credentials.Password)
+ if resp.HasRaw() && resp.Raw.Credentials != nil {
+ if username := resp.Raw.Credentials.Username; username != nil && *username != "" {
+ p.Outputf("Username: %s\n", utils.PtrString(username))
+ }
+ if !showPassword {
+ p.Outputf("Password: \n")
+ } else {
+ p.Outputf("Password: %s\n", utils.PtrString(resp.Raw.Credentials.Password))
+ }
+ p.Outputf("Host: %s\n", utils.PtrString(resp.Raw.Credentials.Host))
+ p.Outputf("Port: %s\n", utils.PtrString(resp.Raw.Credentials.Port))
}
- p.Outputf("Host: %s\n", *resp.Raw.Credentials.Host)
- p.Outputf("Port: %d\n", *resp.Raw.Credentials.Port)
- p.Outputf("URI: %s\n", *resp.Uri)
+ 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 ff0d16fbd..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)
})
}
}
@@ -200,3 +165,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ showPassword bool
+ instanceLabel string
+ credentials *logme.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty credentials",
+ args: args{
+ credentials: &logme.CredentialsResponse{},
+ },
+ 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.showPassword, 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/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 e2b7551a9..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
}
@@ -112,41 +104,31 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *logme.APICl
}
func outputResult(p *print.Printer, outputFormat string, credentials *logme.CredentialsResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal LogMe credentials: %w", err)
- }
- p.Outputln(string(details))
+ if credentials == nil {
+ return fmt.Errorf("credentials is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, credentials, func() error {
table := tables.NewTable()
- table.AddRow("ID", *credentials.Id)
+ table.AddRow("ID", utils.PtrString(credentials.Id))
table.AddSeparator()
// The username field cannot be set by the user so we only display it if it's not returned empty
- username := *credentials.Raw.Credentials.Username
- if username != "" {
- table.AddRow("USERNAME", *credentials.Raw.Credentials.Username)
- table.AddSeparator()
+ if raw := credentials.Raw; raw != nil {
+ if cred := raw.Credentials; cred != nil {
+ if username := cred.Username; username != nil && *username != "" {
+ table.AddRow("USERNAME", *username)
+ table.AddSeparator()
+ }
+ table.AddRow("PASSWORD", utils.PtrString(cred.Password))
+ table.AddSeparator()
+ table.AddRow("URI", utils.PtrString(cred.Uri))
+ }
}
- table.AddRow("PASSWORD", *credentials.Raw.Credentials.Password)
- table.AddSeparator()
- table.AddRow("URI", *credentials.Raw.Credentials.Uri)
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/logme/credentials/describe/describe_test.go b/internal/cmd/logme/credentials/describe/describe_test.go
index 7726b725c..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)
})
}
}
@@ -243,3 +199,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials *logme.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty credentials",
+ args: args{
+ credentials: &logme.CredentialsResponse{},
+ },
+ 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.credentials); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/logme/credentials/list/list.go b/internal/cmd/logme/credentials/list/list.go
index 653902d56..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"
@@ -15,6 +15,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/logme"
@@ -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 LogMe instance",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 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)
@@ -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,30 +124,18 @@ 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))
- 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 {
c := credentials[i]
- table.AddRow(*c.Id)
+ table.AddRow(utils.PtrString(c.Id))
}
err := table.Display(p)
if err != nil {
@@ -165,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 570b58bae..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)
})
}
}
@@ -207,3 +170,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ credentials []logme.CredentialsListItem
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty credentials slice",
+ args: args{
+ credentials: []logme.CredentialsListItem{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty credential in credentials slice",
+ args: args{
+ credentials: []logme.CredentialsListItem{{}},
+ },
+ 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.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 7e2b15e2f..c0b1fcd48 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",
@@ -74,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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,7 +114,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating instance")
_, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -125,7 +123,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
s.Stop()
}
- return outputResult(p, model, 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
}
@@ -251,30 +241,17 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient logMeClient)
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *logme.CreateInstanceResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal LogMe instance: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, resp *logme.CreateInstanceResponse) error {
+ if resp == nil {
+ return fmt.Errorf("response is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Created"
- if model.Async {
+ if async {
operationState = "Triggered creation of"
}
- p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, *resp.InstanceId)
+ 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 fe0cfcc0b..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)
})
}
}
@@ -463,3 +411,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ projectLabel string
+ resp *logme.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty response",
+ args: args{
+ resp: &logme.CreateInstanceResponse{},
+ },
+ 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/logme/instance/delete/delete.go b/internal/cmd/logme/instance/delete/delete.go
index 315a2b3be..9cdfac9d0 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,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
_, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
if err != nil {
@@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 9e477f16a..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
}
@@ -100,39 +92,28 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *logme.APICl
}
func outputResult(p *print.Printer, outputFormat string, instance *logme.Instance) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal LogMe instance: %w", err)
- }
- p.Outputln(string(details))
+ if instance == nil {
+ return fmt.Errorf("instance is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, instance, func() error {
table := tables.NewTable()
- table.AddRow("ID", *instance.InstanceId)
+ table.AddRow("ID", utils.PtrString(instance.InstanceId))
table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("LAST OPERATION TYPE", *instance.LastOperation.Type)
- table.AddSeparator()
- table.AddRow("LAST OPERATION STATE", *instance.LastOperation.State)
- table.AddSeparator()
- table.AddRow("PLAN ID", *instance.PlanId)
+ if instance.LastOperation != nil {
+ table.AddRow("LAST OPERATION TYPE", utils.PtrString(instance.LastOperation.Type))
+ table.AddSeparator()
+ table.AddRow("LAST OPERATION STATE", utils.PtrString(instance.LastOperation.State))
+ table.AddSeparator()
+ }
+ table.AddRow("PLAN ID", utils.PtrString(instance.PlanId))
// Only show ACL if it's present and not empty
- acl := (*instance.Parameters)[aclParameterKey]
- aclStr, ok := acl.(string)
- if ok {
- if aclStr != "" {
+ if instance.Parameters != nil {
+ acl := (*instance.Parameters)[aclParameterKey]
+ aclStr, ok := acl.(string)
+ if ok && aclStr != "" {
table.AddSeparator()
table.AddRow("ACL", aclStr)
}
@@ -143,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 60cdc69ab..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)
})
}
}
@@ -216,3 +172,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *logme.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty instance",
+ args: args{
+ instance: &logme.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.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 24e3cf999..20efce498 100644
--- a/internal/cmd/logme/instance/list/list.go
+++ b/internal/cmd/logme/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/logme"
)
@@ -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 LogMe instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +65,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 +78,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 +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,15 +109,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,30 +118,30 @@ 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))
- 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 {
instance := instances[i]
- table.AddRow(*instance.InstanceId, *instance.Name, *instance.LastOperation.Type, *instance.LastOperation.State)
+
+ lastOperationType, lastOperationState := "", ""
+ if instance.LastOperation != nil {
+ lastOperationType = utils.PtrString(instance.LastOperation.Type)
+ lastOperationState = utils.PtrString(instance.LastOperation.State)
+ }
+
+ table.AddRow(
+ utils.PtrString(instance.InstanceId),
+ utils.PtrString(instance.Name),
+ lastOperationType,
+ lastOperationState,
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,5 +149,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 5524c41e9..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)
})
}
}
@@ -186,3 +146,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ instances []logme.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty instances slice",
+ args: args{
+ instances: []logme.Instance{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty instance in instances slice",
+ args: args{
+ instances: []logme.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/logme/instance/update/update.go b/internal/cmd/logme/instance/update/update.go
index 08972358e..4cefddf26 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,7 +113,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Updating instance")
_, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -126,7 +126,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 dfa14ed0e..5b1597bf4 100644
--- a/internal/cmd/logme/plans/plans.go
+++ b/internal/cmd/logme/plans/plans.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/logme/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/logme"
)
@@ -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: "plans",
Short: "Lists all LogMe service plans",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +65,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 +78,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 +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,15 +109,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,34 +118,30 @@ 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))
- 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 {
o := plans[i]
- for j := range *o.Plans {
- p := (*o.Plans)[j]
- table.AddRow(*o.Name, *o.Version, *p.Id, *p.Name, *p.Description)
+ if o.Plans != nil {
+ for j := range *o.Plans {
+ p := (*o.Plans)[j]
+ table.AddRow(
+ utils.PtrString(o.Name),
+ utils.PtrString(o.Version),
+ utils.PtrString(p.Id),
+ utils.PtrString(p.Name),
+ utils.PtrString(p.Description),
+ )
+ }
+ table.AddSeparator()
}
- table.AddSeparator()
}
table.EnableAutoMergeOnColumns(1, 2)
err := table.Display(p)
@@ -165,5 +150,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 f8214f2f2..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)
})
}
}
@@ -186,3 +146,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ plans []logme.Offering
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty plans slice",
+ args: args{
+ plans: []logme.Offering{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty plan in plans slice",
+ args: args{
+ plans: []logme.Offering{{}},
+ },
+ 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.plans); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/mariadb/credentials/create/create.go b/internal/cmd/mariadb/credentials/create/create.go
index 4373782ea..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",
@@ -47,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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, 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
}
@@ -123,42 +113,31 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *mariadb.API
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *mariadb.CredentialsResponse) error {
- if !model.ShowPassword {
- resp.Raw.Credentials.Password = utils.Ptr("hidden")
+func outputResult(p *print.Printer, outputFormat string, showPassword bool, instanceLabel string, resp *mariadb.CredentialsResponse) error {
+ if resp == nil {
+ return fmt.Errorf("response is nil")
}
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal Argus credentials list: %w", err)
- }
- p.Outputln(string(details))
+ if !showPassword && resp.HasRaw() && resp.Raw.Credentials != nil {
+ resp.Raw.Credentials.Password = utils.Ptr("hidden")
+ }
- return nil
- default:
- p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, *resp.Id)
- // The username field cannot be set by the user so we only display it if it's not returned empty
- username := *resp.Raw.Credentials.Username
- if username != "" {
- p.Outputf("Username: %s\n", *resp.Raw.Credentials.Username)
- }
- if !model.ShowPassword {
- p.Outputf("Password: \n")
- } else {
- p.Outputf("Password: %s\n", *resp.Raw.Credentials.Password)
+ 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 {
+ if username := resp.Raw.Credentials.Username; username != nil && *username != "" {
+ p.Outputf("Username: %s\n", *username)
+ }
+ if !showPassword {
+ p.Outputf("Password: \n")
+ } else {
+ p.Outputf("Password: %s\n", utils.PtrString(resp.Raw.Credentials.Password))
+ }
+ p.Outputf("Host: %s\n", utils.PtrString(resp.Raw.Credentials.Host))
+ p.Outputf("Port: %s\n", utils.PtrString(resp.Raw.Credentials.Port))
}
- p.Outputf("Host: %s\n", *resp.Raw.Credentials.Host)
- p.Outputf("Port: %d\n", *resp.Raw.Credentials.Port)
- p.Outputf("URI: %s\n", *resp.Uri)
+ 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 9d012577b..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)
})
}
}
@@ -200,3 +165,40 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ showPassword bool
+ instanceLabel string
+ credentials *mariadb.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty credentials",
+ args: args{
+ credentials: &mariadb.CredentialsResponse{},
+ },
+ 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.showPassword, 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/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 ae5eeece0..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
}
@@ -112,41 +104,29 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *mariadb.API
}
func outputResult(p *print.Printer, outputFormat string, credentials *mariadb.CredentialsResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal MariaDB credentials: %w", err)
- }
- p.Outputln(string(details))
+ if credentials == nil {
+ return fmt.Errorf("credentials is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, credentials, func() error {
table := tables.NewTable()
- table.AddRow("ID", *credentials.Id)
+ table.AddRow("ID", utils.PtrString(credentials.Id))
table.AddSeparator()
// The username field cannot be set by the user so we only display it if it's not returned empty
- username := *credentials.Raw.Credentials.Username
- if username != "" {
- table.AddRow("USERNAME", *credentials.Raw.Credentials.Username)
+ if credentials.HasRaw() && credentials.Raw.Credentials != nil {
+ if username := credentials.Raw.Credentials.Username; username != nil && *username != "" {
+ table.AddRow("USERNAME", *username)
+ table.AddSeparator()
+ }
+ table.AddRow("PASSWORD", utils.PtrString(credentials.Raw.Credentials.Password))
table.AddSeparator()
+ table.AddRow("URI", utils.PtrString(credentials.Raw.Credentials.Uri))
}
- table.AddRow("PASSWORD", *credentials.Raw.Credentials.Password)
- table.AddSeparator()
- table.AddRow("URI", *credentials.Raw.Credentials.Uri)
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/mariadb/credentials/describe/describe_test.go b/internal/cmd/mariadb/credentials/describe/describe_test.go
index 27e96b383..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)
})
}
}
@@ -243,3 +199,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials *mariadb.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty credentials",
+ args: args{
+ credentials: &mariadb.CredentialsResponse{},
+ },
+ 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.credentials); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/mariadb/credentials/list/list.go b/internal/cmd/mariadb/credentials/list/list.go
index 75a438804..aa79aadf7 100644
--- a/internal/cmd/mariadb/credentials/list/list.go
+++ b/internal/cmd/mariadb/credentials/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/client"
mariadbUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/mariadb"
)
@@ -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' IDs for a MariaDB instance",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +67,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 +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{}
@@ -117,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
}
@@ -134,30 +123,18 @@ 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))
- 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 {
c := credentials[i]
- table.AddRow(*c.Id)
+ table.AddRow(utils.PtrString(c.Id))
}
err := table.Display(p)
if err != nil {
@@ -165,5 +142,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 ab667fbb4..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)
})
}
}
@@ -207,3 +170,46 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ credentials []mariadb.CredentialsListItem
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty credentials slice",
+ args: args{
+ credentials: []mariadb.CredentialsListItem{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty credential in credentials slice",
+ args: args{
+ credentials: []mariadb.CredentialsListItem{{}},
+ },
+ 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.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 754b50fcd..22a8a2b91 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",
@@ -74,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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,7 +114,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating instance")
_, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -125,7 +123,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
s.Stop()
}
- return outputResult(p, model, 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
}
@@ -251,30 +241,17 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient mariaDBClien
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *mariadb.CreateInstanceResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal MariaDB instance: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, resp *mariadb.CreateInstanceResponse) error {
+ if resp == nil {
+ return fmt.Errorf("response is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Created"
- if model.Async {
+ if async {
operationState = "Triggered creation of"
}
- p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, *resp.InstanceId)
+ 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 554b7db9a..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)
})
}
}
@@ -463,3 +411,40 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ projectLabel string
+ resp *mariadb.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty response",
+ args: args{
+ resp: &mariadb.CreateInstanceResponse{},
+ },
+ 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/mariadb/instance/delete/delete.go b/internal/cmd/mariadb/instance/delete/delete.go
index e28fb3593..1cd809204 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,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
_, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
if err != nil {
@@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 94323bc5e..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
}
@@ -100,41 +92,32 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *mariadb.API
}
func outputResult(p *print.Printer, outputFormat string, instance *mariadb.Instance) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal MariaDB instance: %w", err)
- }
- p.Outputln(string(details))
+ if instance == nil {
+ return fmt.Errorf("instance is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, instance, func() error {
table := tables.NewTable()
- table.AddRow("ID", *instance.InstanceId)
+ table.AddRow("ID", utils.PtrString(instance.InstanceId))
table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("LAST OPERATION TYPE", *instance.LastOperation.Type)
- table.AddSeparator()
- table.AddRow("LAST OPERATION STATE", *instance.LastOperation.State)
- table.AddSeparator()
- table.AddRow("PLAN ID", *instance.PlanId)
+ if instance.LastOperation != nil {
+ table.AddRow("LAST OPERATION TYPE", utils.PtrString(instance.LastOperation.Type))
+ table.AddSeparator()
+ table.AddRow("LAST OPERATION STATE", utils.PtrString(instance.LastOperation.State))
+ table.AddSeparator()
+ }
+ table.AddRow("PLAN ID", utils.PtrString(instance.PlanId))
// Only show ACL if it's present and not empty
- acl := (*instance.Parameters)[aclParameterKey]
- aclStr, ok := acl.(string)
- if ok {
- if aclStr != "" {
- table.AddSeparator()
- table.AddRow("ACL", aclStr)
+ if instance.Parameters != nil {
+ acl := (*instance.Parameters)[aclParameterKey]
+ aclStr, ok := acl.(string)
+ if ok {
+ if aclStr != "" {
+ table.AddSeparator()
+ table.AddRow("ACL", aclStr)
+ }
}
}
err := table.Display(p)
@@ -143,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 f7bb4cfca..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)
})
}
}
@@ -216,3 +172,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *mariadb.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty instance",
+ args: args{
+ instance: &mariadb.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.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 4a3b31672..5c13db105 100644
--- a/internal/cmd/mariadb/instance/list/list.go
+++ b/internal/cmd/mariadb/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/mariadb"
)
@@ -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 MariaDB instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +65,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 +78,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 +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,15 +109,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,30 +118,30 @@ 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))
- 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 {
instance := instances[i]
- table.AddRow(*instance.InstanceId, *instance.Name, *instance.LastOperation.Type, *instance.LastOperation.State)
+
+ lastOperationType, lastOperationState := "", ""
+ if instance.LastOperation != nil {
+ lastOperationType = utils.PtrString(instance.LastOperation.Type)
+ lastOperationState = utils.PtrString(instance.LastOperation.State)
+ }
+
+ table.AddRow(
+ utils.PtrString(instance.InstanceId),
+ utils.PtrString(instance.Name),
+ lastOperationType,
+ lastOperationState,
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,5 +149,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 82ed3955f..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)
})
}
}
@@ -186,3 +146,46 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ instances []mariadb.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty instances slice",
+ args: args{
+ instances: []mariadb.Instance{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty instance in instances slice",
+ args: args{
+ instances: []mariadb.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/mariadb/instance/update/update.go b/internal/cmd/mariadb/instance/update/update.go
index a2b70759e..ebb48e315 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,7 +111,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Updating instance")
_, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -124,7 +124,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 4fb14c3c2..5a6d9f017 100644
--- a/internal/cmd/mariadb/plans/plans.go
+++ b/internal/cmd/mariadb/plans/plans.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/mariadb/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/mariadb"
)
@@ -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: "plans",
Short: "Lists all MariaDB service plans",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +65,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 +78,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 +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,15 +109,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,34 +118,30 @@ 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))
- 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 {
o := plans[i]
- for j := range *o.Plans {
- plan := (*o.Plans)[j]
- table.AddRow(*o.Name, *o.Version, *plan.Id, *plan.Name, *plan.Description)
+ if o.Plans != nil {
+ for j := range *o.Plans {
+ plan := (*o.Plans)[j]
+ table.AddRow(
+ utils.PtrString(o.Name),
+ utils.PtrString(o.Version),
+ utils.PtrString(plan.Id),
+ utils.PtrString(plan.Name),
+ utils.PtrString(plan.Description),
+ )
+ }
+ table.AddSeparator()
}
- table.AddSeparator()
}
table.EnableAutoMergeOnColumns(1, 2)
err := table.Display(p)
@@ -165,5 +150,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 7c6e4b7d7..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)
})
}
}
@@ -186,3 +146,46 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ plans []mariadb.Offering
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty plans slice",
+ args: args{
+ plans: []mariadb.Offering{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty plan in plans slice",
+ args: args{
+ plans: []mariadb.Offering{{}},
+ },
+ 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.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 6488c6ee7..17bbe9b40 100644
--- a/internal/cmd/mongodbflex/backup/describe/describe.go
+++ b/internal/cmd/mongodbflex/backup/describe/describe.go
@@ -2,11 +2,10 @@ package describe
import (
"context"
- "encoding/json"
"fmt"
- "github.com/goccy/go-yaml"
- "github.com/inhies/go-bytesize"
+ "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"
@@ -15,8 +14,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/mongodbflex/client"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/utils"
+ 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"
)
@@ -33,7 +33,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 +49,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 := utils.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 +74,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 := utils.GetRestoreStatus(model.BackupId, restoreJobs)
- return outputResult(p, cmd, model.OutputFormat, restoreJobState, *resp.Item)
+ restoreJobState := mongoUtils.GetRestoreStatus(model.BackupId, restoreJobs)
+ return outputResult(params.Printer, model.OutputFormat, restoreJobState, *resp.Item)
},
}
configureFlags(cmd)
@@ -108,50 +108,26 @@ 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, cmd *cobra.Command, 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)
- }
- cmd.Println(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(backup, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal MongoDB Flex backup: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+func outputResult(p *print.Printer, outputFormat, restoreStatus string, backup mongodbflex.Backup) error {
+ return p.OutputResult(outputFormat, backup, func() error {
table := tables.NewTable()
- table.AddRow("ID", *backup.Id)
+ table.AddRow("ID", utils.PtrString(backup.Id))
table.AddSeparator()
- table.AddRow("CREATED AT", *backup.StartTime)
+ table.AddRow("CREATED AT", utils.PtrString(backup.StartTime))
table.AddSeparator()
- table.AddRow("EXPIRES AT", *backup.EndTime)
+ table.AddRow("EXPIRES AT", utils.PtrString(backup.EndTime))
table.AddSeparator()
- table.AddRow("BACKUP SIZE", bytesize.New(float64(*backup.Size)))
+ backupSize := utils.PtrByteSizeDefault(backup.Size, "n/a")
+ table.AddRow("BACKUP SIZE", backupSize)
table.AddSeparator()
table.AddRow("RESTORE STATUS", restoreStatus)
@@ -161,5 +137,5 @@ func outputResult(p *print.Printer, cmd *cobra.Command, outputFormat, restoreSta
}
return nil
- }
+ })
}
diff --git a/internal/cmd/mongodbflex/backup/describe/describe_test.go b/internal/cmd/mongodbflex/backup/describe/describe_test.go
index 1d6dc44e4..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)
})
}
}
@@ -237,3 +197,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ backup mongodbflex.Backup
+ restoreStatus string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set backup",
+ args: args{
+ backup: mongodbflex.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.restoreStatus, tt.args.backup); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/mongodbflex/backup/list/list.go b/internal/cmd/mongodbflex/backup/list/list.go
index f1a26ce75..b75955b7a 100644
--- a/internal/cmd/mongodbflex/backup/list/list.go
+++ b/internal/cmd/mongodbflex/backup/list/list.go
@@ -2,11 +2,10 @@ package list
import (
"context"
- "encoding/json"
"fmt"
- "github.com/goccy/go-yaml"
- "github.com/inhies/go-bytesize"
+ "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,6 +15,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/mongodbflex"
@@ -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",
@@ -52,20 +52,20 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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,48 +123,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 *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 {
- switch outputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(backups, "", " ")
- if err != nil {
- return fmt.Errorf("marshal MongoDB Flex backups list: %w", err)
- }
- p.Outputln(string(details))
+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")
+ }
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(backups, yaml.IndentSequence(true))
- 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
- default:
table := tables.NewTable()
table.SetHeader("ID", "CREATED AT", "EXPIRES AT", "BACKUP SIZE", "RESTORE STATUS")
for i := range backups {
backup := backups[i]
restoreStatus := mongodbflexUtils.GetRestoreStatus(*backup.Id, restoreJobs)
- table.AddRow(*backup.Id, *backup.StartTime, *backup.EndTime, bytesize.New(float64(*backup.Size)), restoreStatus)
+ backupSize := utils.PtrByteSizeDefault(backup.Size, "n/a")
+ table.AddRow(
+ utils.PtrString(backup.Id),
+ utils.PtrString(backup.StartTime),
+ utils.PtrString(backup.EndTime),
+ backupSize,
+ restoreStatus)
}
err := table.Display(p)
if err != nil {
@@ -176,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 82de1efca..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)
})
}
}
@@ -207,3 +176,54 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ backups []mongodbflex.Backup
+ restoreJobs *mongodbflex.ListRestoreJobsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty backups",
+ args: args{
+ backups: []mongodbflex.Backup{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "set restore jobs",
+ args: args{
+ restoreJobs: &mongodbflex.ListRestoreJobsResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set restore jobs and empty backups",
+ args: args{
+ backups: []mongodbflex.Backup{},
+ restoreJobs: &mongodbflex.ListRestoreJobsResponse{},
+ },
+ 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.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 5d5d0cad8..1822b4583 100644
--- a/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs.go
+++ b/internal/cmd/mongodbflex/backup/restore-jobs/restore_jobs.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ 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/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/mongodbflex"
)
@@ -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: "restore-jobs",
Short: "Lists all restore jobs which have been run for a MongoDB Flex instance",
@@ -51,20 +51,20 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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 +85,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 +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{}
@@ -121,48 +121,29 @@ 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))
- 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 {
restoreJob := restoreJobs[i]
- table.AddRow(*restoreJob.Id, *restoreJob.BackupID, *restoreJob.InstanceId, *restoreJob.Date, *restoreJob.Status)
+ table.AddRow(
+ utils.PtrString(restoreJob.Id),
+ utils.PtrString(restoreJob.BackupID),
+ utils.PtrString(restoreJob.InstanceId),
+ utils.PtrString(restoreJob.Date),
+ utils.PtrString(restoreJob.Status),
+ )
}
err := table.Display(p)
if err != nil {
@@ -170,5 +151,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 c7c5ec84a..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)
})
}
}
@@ -207,3 +176,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ restoreJobs []mongodbflex.RestoreInstanceStatus
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty restore jobs",
+ args: args{
+ restoreJobs: []mongodbflex.RestoreInstanceStatus{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty restore job",
+ args: args{
+ restoreJobs: []mongodbflex.RestoreInstanceStatus{{}},
+ },
+ 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.restoreJobs); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/mongodbflex/backup/restore/restore.go b/internal/cmd/mongodbflex/backup/restore/restore.go
index d74611db3..a4b6e5ca9 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",
@@ -58,29 +60,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
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,16 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Restoring instance")
- _, err = wait.RestoreInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.BackupId).WaitWithContext(ctx)
+ _, err = wait.RestoreInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.BackupId, model.Region).WaitWithContext(ctx)
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)
+ params.Printer.Outputf("Restored instance %q with backup %q\n", model.InstanceId, model.BackupId)
return nil
}
@@ -120,16 +120,16 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Cloning instance")
- _, err = wait.CloneInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
+ _, err = wait.CloneInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region).WaitWithContext(ctx)
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)
+ params.Printer.Outputf("Cloned instance %q from backup with timestamp %q\n", model.InstanceId, model.Timestamp)
return nil
},
}
@@ -147,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{}
@@ -170,20 +170,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 +184,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 1e899fe22..614b42082 100644
--- a/internal/cmd/mongodbflex/backup/schedule/schedule.go
+++ b/internal/cmd/mongodbflex/backup/schedule/schedule.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -14,8 +15,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/mongodbflex"
)
@@ -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: "schedule",
Short: "Shows details of the backup schedule and retention policy of a MongoDB Flex 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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 MongoDB Flex instance: %w", err)
}
- return outputResult(p, model.OutputFormat, resp.Item)
+ return outputResult(params.Printer, model.OutputFormat, resp.Item)
},
}
configureFlags(cmd)
@@ -75,7 +75,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,24 +86,20 @@ 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
}
func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.Instance) error {
+ if instance == nil {
+ return fmt.Errorf("instance is nil")
+ }
+
output := struct {
BackupSchedule string `json:"backup_schedule"`
DailySnaphotRetentionDays string `json:"daily_snapshot_retention_days"`
@@ -112,50 +108,36 @@ func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.I
SnapshotRetentionDays string `json:"snapshot_retention_days"`
WeeklySnapshotRetentionWeeks string `json:"weekly_snapshot_retention_weeks"`
}{
- BackupSchedule: *instance.BackupSchedule,
- DailySnaphotRetentionDays: (*instance.Options)["dailySnapshotRetentionDays"],
- MonthlySnapshotRetentionMonths: (*instance.Options)["monthlySnapshotRetentionDays"],
- PointInTimeWindowHours: (*instance.Options)["pointInTimeWindowHours"],
- SnapshotRetentionDays: (*instance.Options)["snapshotRetentionDays"],
- WeeklySnapshotRetentionWeeks: (*instance.Options)["weeklySnapshotRetentionWeeks"],
+ BackupSchedule: utils.PtrString(instance.BackupSchedule),
+ }
+ if instance.Options != nil {
+ output.DailySnaphotRetentionDays = (*instance.Options)["dailySnapshotRetentionDays"]
+ output.MonthlySnapshotRetentionMonths = (*instance.Options)["monthlySnapshotRetentionDays"]
+ output.PointInTimeWindowHours = (*instance.Options)["pointInTimeWindowHours"]
+ output.SnapshotRetentionDays = (*instance.Options)["snapshotRetentionDays"]
+ 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))
- 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)", *instance.BackupSchedule)
+ table.AddRow("BACKUP SCHEDULE (UTC)", output.BackupSchedule)
table.AddSeparator()
- table.AddRow("DAILY SNAPSHOT RETENTION (DAYS)", (*instance.Options)["dailySnapshotRetentionDays"])
+ table.AddRow("DAILY SNAPSHOT RETENTION (DAYS)", output.DailySnaphotRetentionDays)
table.AddSeparator()
- table.AddRow("MONTHLY SNAPSHOT RETENTION (MONTHS)", (*instance.Options)["monthlySnapshotRetentionMonths"])
+ table.AddRow("MONTHLY SNAPSHOT RETENTION (MONTHS)", output.MonthlySnapshotRetentionMonths)
table.AddSeparator()
- table.AddRow("POINT IN TIME WINDOW (HOURS)", (*instance.Options)["pointInTimeWindowHours"])
+ table.AddRow("POINT IN TIME WINDOW (HOURS)", output.PointInTimeWindowHours)
table.AddSeparator()
- table.AddRow("SNAPSHOT RETENTION (DAYS)", (*instance.Options)["snapshotRetentionDays"])
+ table.AddRow("SNAPSHOT RETENTION (DAYS)", output.SnapshotRetentionDays)
table.AddSeparator()
- table.AddRow("WEEKLY SNAPSHOT RETENTION (WEEKS)", (*instance.Options)["weeklySnapshotRetentionWeeks"])
+ table.AddRow("WEEKLY SNAPSHOT RETENTION (WEEKS)", output.WeeklySnapshotRetentionWeeks)
table.AddSeparator()
+
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/mongodbflex/backup/schedule/schedule_test.go b/internal/cmd/mongodbflex/backup/schedule/schedule_test.go
index e25957058..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)
})
}
}
@@ -193,3 +159,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *mongodbflex.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty instance",
+ args: args{
+ instance: &mongodbflex.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.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 5b2f71bba..6934fb862 100644
--- a/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go
+++ b/internal/cmd/mongodbflex/backup/update-schedule/update_schedule.go
@@ -5,6 +5,8 @@ import (
"fmt"
"strconv"
+ "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"
@@ -46,7 +48,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",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +130,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 +158,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 +228,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 607774a0c..e0239d2f5 100644
--- a/internal/cmd/mongodbflex/instance/create/create.go
+++ b/internal/cmd/mongodbflex/instance/create/create.go
@@ -2,11 +2,12 @@ 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-cli/internal/pkg/args"
cliErr "github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -18,8 +19,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/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/mongodbflex"
"github.com/stackitcloud/stackit-sdk-go/services/mongodbflex/wait"
)
@@ -57,7 +56,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",
@@ -77,34 +76,32 @@ func NewCmd(p *print.Printer) *cobra.Command {
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)
}
@@ -124,16 +121,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 := spinner.New(params.Printer)
s.Start("Creating instance")
- _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
+ _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for MongoDB Flex instance creation: %w", err)
}
s.Stop()
}
- return outputResult(p, model, projectLabel, resp)
+ return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp)
},
}
configureFlags(cmd)
@@ -158,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,31 +192,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)
}
@@ -241,7 +230,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)
}
@@ -257,7 +246,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,
@@ -273,30 +262,17 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexC
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *mongodbflex.CreateInstanceResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal MongoDBFlex instance: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, resp *mongodbflex.CreateInstanceResponse) error {
+ if resp == nil {
+ return fmt.Errorf("create instance response is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Created"
- if model.Async {
+ if async {
operationState = "Triggered creation of"
}
- p.Outputf("%s instance for project %q. Instance ID: %s\n", operationState, projectLabel, *resp.Id)
+ 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 712718e95..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)),
@@ -546,3 +507,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ projectLabel string
+ createInstanceResponse *mongodbflex.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty create instance response",
+ args: args{
+ createInstanceResponse: &mongodbflex.CreateInstanceResponse{},
+ },
+ 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.createInstanceResponse); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/mongodbflex/instance/delete/delete.go b/internal/cmd/mongodbflex/instance/delete/delete.go
index 237681df0..cb4a2be9c 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,9 +75,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
- _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
+ _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId, model.Region).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for MongoDB Flex instance deletion: %w", err)
}
@@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 810ae1a87..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,81 +82,73 @@ 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
}
func outputResult(p *print.Printer, outputFormat string, instance *mongodbflex.Instance) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal MongoDB Flex instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
- aclsArray := *instance.Acl.Items
- acls := strings.Join(aclsArray, ",")
+ if instance == nil {
+ return fmt.Errorf("instance is nil")
+ }
- instanceType, err := mongodbflexUtils.GetInstanceType(*instance.Replicas)
- if err != nil {
- // Should never happen
- instanceType = ""
+ return p.OutputResult(outputFormat, instance, func() error {
+ var instanceType string
+ if instance.HasReplicas() {
+ var err error
+ instanceType, err = mongodbflexUtils.GetInstanceType(*instance.Replicas)
+ if err != nil {
+ // Should never happen
+ instanceType = ""
+ }
}
table := tables.NewTable()
- table.AddRow("ID", *instance.Id)
- table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
- table.AddSeparator()
- table.AddRow("STATUS", *instance.Status)
+ table.AddRow("ID", utils.PtrString(instance.Id))
table.AddSeparator()
- table.AddRow("STORAGE SIZE (GB)", *instance.Storage.Size)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("VERSION", *instance.Version)
+ table.AddRow("STATUS", utils.PtrString(instance.Status))
table.AddSeparator()
- table.AddRow("ACL", acls)
- table.AddSeparator()
- table.AddRow("FLAVOR DESCRIPTION", *instance.Flavor.Description)
+ if instance.HasStorage() {
+ table.AddRow("STORAGE SIZE (GB)", utils.PtrString(instance.Storage.Size))
+ table.AddSeparator()
+ }
+ table.AddRow("VERSION", utils.PtrString(instance.Version))
table.AddSeparator()
+ if instance.HasAcl() {
+ aclsArray := *instance.Acl.Items
+ acls := strings.Join(aclsArray, ",")
+ table.AddRow("ACL", acls)
+ table.AddSeparator()
+ }
+ if instance.HasFlavor() && instance.Flavor.HasDescription() {
+ table.AddRow("FLAVOR DESCRIPTION", *instance.Flavor.Description)
+ table.AddSeparator()
+ }
table.AddRow("TYPE", instanceType)
table.AddSeparator()
- table.AddRow("REPLICAS", *instance.Replicas)
- table.AddSeparator()
- table.AddRow("CPU", *instance.Flavor.Cpu)
- table.AddSeparator()
- table.AddRow("RAM (GB)", *instance.Flavor.Memory)
- table.AddSeparator()
- table.AddRow("BACKUP SCHEDULE (UTC)", *instance.BackupSchedule)
+ if instance.HasReplicas() {
+ table.AddRow("REPLICAS", *instance.Replicas)
+ table.AddSeparator()
+ }
+ if instance.HasFlavor() {
+ table.AddRow("CPU", utils.PtrString(instance.Flavor.Cpu))
+ table.AddSeparator()
+ table.AddRow("RAM (GB)", utils.PtrString(instance.Flavor.Memory))
+ table.AddSeparator()
+ }
+ table.AddRow("BACKUP SCHEDULE (UTC)", utils.PtrString(instance.BackupSchedule))
table.AddSeparator()
- err = table.Display(p)
+ err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/mongodbflex/instance/describe/describe_test.go b/internal/cmd/mongodbflex/instance/describe/describe_test.go
index dbdb934f7..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)
})
}
}
@@ -216,3 +176,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *mongodbflex.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty instance",
+ args: args{
+ instance: &mongodbflex.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.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 443c7ef08..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"
@@ -15,6 +15,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"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/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/mongodbflex"
@@ -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 MongoDB Flex instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 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)
},
}
@@ -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,47 +110,31 @@ 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))
- 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 {
instance := instances[i]
- table.AddRow(*instance.Id, *instance.Name, *instance.Status)
+ table.AddRow(
+ utils.PtrString(instance.Id),
+ utils.PtrString(instance.Name),
+ utils.PtrString(instance.Status),
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,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 c8e8716d5..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)
})
}
}
@@ -186,3 +152,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ instanceList []mongodbflex.InstanceListInstance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty instance list slice",
+ args: args{
+ instanceList: []mongodbflex.InstanceListInstance{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty instance in instance list slice",
+ args: args{
+ instanceList: []mongodbflex.InstanceListInstance{{}},
+ },
+ 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.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 bc3dba632..bcd4bb894 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 := spinner.New(params.Printer)
s.Start("Updating instance")
- _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
+ _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId, model.Region).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for MongoDB Flex instance update: %w", err)
}
s.Stop()
}
- return outputResult(p, model, 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)
}
@@ -307,30 +297,17 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient MongoDBFlexC
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *mongodbflex.UpdateInstanceResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal update MongoDBFlex instance: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, instanceLabel string, resp *mongodbflex.UpdateInstanceResponse) error {
+ if resp == nil {
+ return fmt.Errorf("resp is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Updated"
- if model.Async {
+ 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 6e15e4408..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),
@@ -590,3 +598,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ instanceLabel string
+ resp *mongodbflex.UpdateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty response",
+ args: args{
+ resp: &mongodbflex.UpdateInstanceResponse{},
+ },
+ 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.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 2480e5b10..73febf6cf 100644
--- a/internal/cmd/mongodbflex/options/options.go
+++ b/internal/cmd/mongodbflex/options/options.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
@@ -13,8 +14,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/mongodbflex/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/mongodbflex"
)
@@ -35,9 +35,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 +45,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",
@@ -64,19 +64,19 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +95,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 +123,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 +140,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)
}
@@ -170,6 +162,10 @@ func buildAndExecuteRequest(ctx context.Context, p *print.Printer, model *inputM
}
func outputResult(p *print.Printer, model *inputModel, flavors *mongodbflex.ListFlavorsResponse, versions *mongodbflex.ListVersionsResponse, storages *mongodbflex.ListStoragesResponse) error {
+ if model == nil || model.GlobalFlagModel == nil {
+ return fmt.Errorf("model is nil")
+ }
+
options := &options{}
if flavors != nil {
options.Flavors = flavors.Flavors
@@ -184,40 +180,30 @@ 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))
- 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 {
- content := ""
- if model.Flavors {
- content += renderFlavors(*options.Flavors)
+ if model == nil {
+ return fmt.Errorf("model is nil")
+ } else if options == nil {
+ return fmt.Errorf("options is nil")
}
- if model.Versions {
- content += renderVersions(*options.Versions)
+
+ content := []tables.Table{}
+ if model.Flavors && len(*options.Flavors) != 0 {
+ content = append(content, buildFlavorsTable(*options.Flavors))
}
- if model.Storages {
- content += renderStorages(options.Storages.Storages)
+ 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 := p.PagerDisplay(content)
+ err := tables.DisplayTables(p, content)
if err != nil {
return fmt.Errorf("display output: %w", err)
}
@@ -225,26 +211,24 @@ func outputResultAsTable(p *print.Printer, model *inputModel, options *options)
return nil
}
-func renderFlavors(flavors []mongodbflex.HandlersInfraFlavor) string {
- if len(flavors) == 0 {
- return ""
- }
-
+func buildFlavorsTable(flavors []mongodbflex.InstanceFlavor) tables.Table {
table := tables.NewTable()
table.SetTitle("Flavors")
table.SetHeader("ID", "CPU", "MEMORY", "DESCRIPTION", "VALID INSTANCE TYPES")
for i := range flavors {
f := flavors[i]
- table.AddRow(*f.Id, *f.Cpu, *f.Memory, *f.Description, *f.Categories)
+ table.AddRow(
+ utils.PtrString(f.Id),
+ utils.PtrString(f.Cpu),
+ utils.PtrString(f.Memory),
+ utils.PtrString(f.Description),
+ utils.PtrString(f.Categories),
+ )
}
- return table.Render()
+ return table
}
-func renderVersions(versions []string) string {
- if len(versions) == 0 {
- return ""
- }
-
+func buildVersionsTable(versions []string) tables.Table {
table := tables.NewTable()
table.SetTitle("Versions")
table.SetHeader("VERSION")
@@ -252,22 +236,22 @@ func renderVersions(versions []string) string {
v := versions[i]
table.AddRow(v)
}
- return table.Render()
+ return table
}
-func renderStorages(resp *mongodbflex.ListStoragesResponse) string {
- if resp.StorageClasses == nil || len(*resp.StorageClasses) == 0 {
- return ""
- }
- storageClasses := *resp.StorageClasses
-
+func buildStoragesTable(storagesResp mongodbflex.ListStoragesResponse) tables.Table {
+ storages := *storagesResp.StorageClasses
table := tables.NewTable()
table.SetTitle("Storages")
table.SetHeader("MINIMUM", "MAXIMUM", "STORAGE CLASS")
- for i := range storageClasses {
- sc := storageClasses[i]
- table.AddRow(*resp.StorageRange.Min, *resp.StorageRange.Max, sc)
+ for i := range storages {
+ sc := storages[i]
+ table.AddRow(
+ utils.PtrString(storagesResp.StorageRange.Min),
+ utils.PtrString(storagesResp.StorageRange.Max),
+ sc,
+ )
}
table.EnableAutoMergeOnColumns(1, 2, 3)
- return table.Render()
+ return table
}
diff --git a/internal/cmd/mongodbflex/options/options_test.go b/internal/cmd/mongodbflex/options/options_test.go
index d750bc6e1..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,
@@ -322,3 +286,145 @@ func TestBuildAndExecuteRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ inputModel *inputModel
+ flavors *mongodbflex.ListFlavorsResponse
+ versions *mongodbflex.ListVersionsResponse
+ storages *mongodbflex.ListStoragesResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "missing model",
+ args: args{
+ flavors: &mongodbflex.ListFlavorsResponse{},
+ versions: &mongodbflex.ListVersionsResponse{},
+ storages: &mongodbflex.ListStoragesResponse{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty model",
+ args: args{
+ inputModel: &inputModel{},
+ flavors: &mongodbflex.ListFlavorsResponse{},
+ versions: &mongodbflex.ListVersionsResponse{},
+ storages: &mongodbflex.ListStoragesResponse{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "ok",
+ args: args{
+ inputModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ flavors: &mongodbflex.ListFlavorsResponse{},
+ versions: &mongodbflex.ListVersionsResponse{},
+ storages: &mongodbflex.ListStoragesResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing flavors",
+ args: args{
+ inputModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ versions: &mongodbflex.ListVersionsResponse{},
+ storages: &mongodbflex.ListStoragesResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing versions",
+ args: args{
+ inputModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ flavors: &mongodbflex.ListFlavorsResponse{},
+ storages: &mongodbflex.ListStoragesResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing storages",
+ args: args{
+ inputModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ flavors: &mongodbflex.ListFlavorsResponse{},
+ versions: &mongodbflex.ListVersionsResponse{},
+ },
+ 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.inputModel, tt.args.flavors, tt.args.versions, tt.args.storages); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestOutputResultAsTable(t *testing.T) {
+ type args struct {
+ model *inputModel
+ options *options
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "missing input model",
+ args: args{
+ options: &options{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing options",
+ args: args{
+ model: &inputModel{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty input model and empty options",
+ args: args{
+ model: &inputModel{},
+ options: &options{},
+ },
+ 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 := outputResultAsTable(p, tt.args.model, tt.args.options); (err != nil) != tt.wantErr {
+ t.Errorf("outputResultAsTable() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/mongodbflex/user/create/create.go b/internal/cmd/mongodbflex/user/create/create.go
index 8e8b743c5..9fc9def6d 100644
--- a/internal/cmd/mongodbflex/user/create/create.go
+++ b/internal/cmd/mongodbflex/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/spf13/cobra"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
@@ -15,6 +15,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"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"
)
@@ -38,7 +39,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",
@@ -59,29 +60,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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
@@ -92,7 +91,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
user := resp.Item
- return outputResult(p, model, instanceLabel, user)
+ return outputResult(params.Printer, model.OutputFormat, instanceLabel, user)
},
}
@@ -101,18 +100,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{}
@@ -126,20 +125,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,
@@ -148,34 +139,21 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, user *mongodbflex.User) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal MongoDB Flex user: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, instanceLabel string, user *mongodbflex.User) error {
+ if user == nil {
+ return fmt.Errorf("user is nil")
+ }
- return nil
- default:
- p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, *user.Id)
- p.Outputf("Username: %s\n", *user.Username)
- p.Outputf("Password: %s\n", *user.Password)
- p.Outputf("Roles: %v\n", *user.Roles)
- p.Outputf("Database: %s\n", *user.Database)
- p.Outputf("Host: %s\n", *user.Host)
- p.Outputf("Port: %d\n", *user.Port)
- p.Outputf("URI: %s\n", *user.Uri)
+ 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))
+ p.Outputf("Roles: %v\n", utils.PtrString(user.Roles))
+ p.Outputf("Database: %s\n", utils.PtrString(user.Database))
+ p.Outputf("Host: %s\n", utils.PtrString(user.Host))
+ p.Outputf("Port: %s\n", utils.PtrString(user.Port))
+ 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 de4ab99dc..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)
})
}
}
@@ -244,3 +210,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ user *mongodbflex.User
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty user",
+ args: args{
+ user: &mongodbflex.User{},
+ },
+ 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.instanceLabel, tt.args.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 c3eb9d5cc..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,54 +100,29 @@ 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))
- 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", *user.Id)
+ table.AddRow("ID", utils.PtrString(user.Id))
table.AddSeparator()
- table.AddRow("USERNAME", *user.Username)
+ table.AddRow("USERNAME", utils.PtrString(user.Username))
table.AddSeparator()
- table.AddRow("ROLES", *user.Roles)
+ table.AddRow("ROLES", utils.PtrString(user.Roles))
table.AddSeparator()
- table.AddRow("DATABASE", *user.Database)
+ table.AddRow("DATABASE", utils.PtrString(user.Database))
table.AddSeparator()
- table.AddRow("HOST", *user.Host)
+ table.AddRow("HOST", utils.PtrString(user.Host))
table.AddSeparator()
- table.AddRow("PORT", *user.Port)
+ table.AddRow("PORT", utils.PtrString(user.Port))
err := table.Display(p)
if err != nil {
@@ -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 c729e1c8e..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)
})
}
}
@@ -243,3 +203,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceResponseUser mongodbflex.InstanceResponseUser
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty user",
+ args: args{
+ instanceResponseUser: mongodbflex.InstanceResponseUser{},
+ },
+ 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.instanceResponseUser); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/mongodbflex/user/list/list.go b/internal/cmd/mongodbflex/user/list/list.go
index ada4d0f6f..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"
@@ -15,6 +15,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/mongodbflex"
@@ -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 MongoDB Flex users of an instance",
@@ -51,13 +52,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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 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)
},
}
@@ -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,47 +118,30 @@ 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))
- 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 {
user := users[i]
- table.AddRow(*user.Id, *user.Username)
+ table.AddRow(
+ utils.PtrString(user.Id),
+ utils.PtrString(user.Username),
+ )
}
err := table.Display(p)
if err != nil {
@@ -168,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 6c1b85424..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)
})
}
}
@@ -203,3 +169,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ users []mongodbflex.ListUser
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty user slice",
+ args: args{
+ users: []mongodbflex.ListUser{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty user in user slice",
+ args: args{
+ users: []mongodbflex.ListUser{{}},
+ },
+ 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.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 665ad9b8b..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, userLabel, instanceLabel, user)
+ return outputResult(params.Printer, model.OutputFormat, userLabel, instanceLabel, user)
},
}
@@ -116,46 +114,25 @@ 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
}
-func outputResult(p *print.Printer, model *inputModel, userLabel, instanceLabel string, user *mongodbflex.User) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal MongoDB Flex reset password: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, userLabel, instanceLabel string, user *mongodbflex.User) error {
+ if user == nil {
+ return fmt.Errorf("user is nil")
+ }
- 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", *user.Username)
- p.Outputf("New password: %s\n", *user.Password)
- p.Outputf("New URI: %s\n", *user.Uri)
+ 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 c477b7fa8..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)
})
}
}
@@ -243,3 +203,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ userLabel string
+ instanceLabel string
+ user *mongodbflex.User
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty user",
+ args: args{
+ user: &mongodbflex.User{},
+ },
+ 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.userLabel, tt.args.instanceLabel, tt.args.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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
new file mode 100644
index 000000000..078a6568c
--- /dev/null
+++ b/internal/cmd/network-area/create/create.go
@@ -0,0 +1,304 @@
+package create
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "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"
+ "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"
+ 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/stackitcloud/stackit-sdk-go/services/iaas/wait"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ 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"
+ // 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
+ // 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
+ // 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(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a STACKIT Network Area (SNA)",
+ Long: "Creates a STACKIT Network Area (SNA) in an organization.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `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-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, 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
+ }
+
+ var orgLabel string
+ rmApiClient, err := rmClient.ConfigureClient(params.Printer, params.CliVersion)
+ if err == nil {
+ orgLabel, err = rmUtils.GetOrganizationName(ctx, rmApiClient, model.OrganizationId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err)
+ orgLabel = model.OrganizationId
+ } else if orgLabel == "" {
+ orgLabel = model.OrganizationId
+ }
+ } else {
+ params.Printer.Debug(print.ErrorLevel, "configure resource manager client: %v", 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Create network area region")
+ _, err = wait.CreateNetworkAreaRegionWaitHandler(ctx, apiClient, model.OrganizationId, *resp.Id, model.Region).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for creating network area region %w", err)
+ }
+ s.Stop()
+ }
+ responses.RegionalArea = respNetworkArea
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, orgLabel, responses)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+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")
+
+ 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 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.FlagToStringValue(p, cmd, organizationIdFlag),
+ DnsNameServers: flags.FlagToStringSlicePointer(p, cmd, dnsNameServersFlag),
+ NetworkRanges: flags.FlagToStringSlicePointer(p, cmd, networkRangesFlag),
+ TransferNetwork: flags.FlagToStringPointer(p, cmd, transferNetworkFlag),
+ DefaultPrefixLength: flags.FlagToInt64Pointer(p, cmd, defaultPrefixLengthFlag),
+ MaxPrefixLength: flags.FlagToInt64Pointer(p, cmd, maxPrefixLengthFlag),
+ MinPrefixLength: flags.FlagToInt64Pointer(p, cmd, minPrefixLengthFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ }
+
+ // 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)
+
+ payload := iaas.CreateNetworkAreaPayload{
+ Name: model.Name,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+
+ 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.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.CreateNetworkAreaRegionPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, orgLabel string, responses *NetworkAreaResponses) error {
+ if responses == nil {
+ return fmt.Errorf("network area is nil")
+ }
+
+ 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
+ }
+ // 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
new file mode 100644
index 000000000..b903ccbba
--- /dev/null
+++ b/internal/cmd/network-area/create/create_test.go
@@ -0,0 +1,518 @@
+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"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "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()
+ 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{
+ globalflags.RegionFlag: testRegion,
+
+ nameFlag: testName,
+ organizationIdFlag: testOrgId,
+ labelFlag: "key=value",
+ }
+ 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,
+ },
+ Name: utils.Ptr("example-network-area-name"),
+ OrganizationId: testOrgId,
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkAreaRequest)) iaas.ApiCreateNetworkAreaRequest {
+ request := testClient.CreateNetworkArea(testCtx, testOrgId)
+ request = request.CreateNetworkAreaPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaPayload)) iaas.CreateNetworkAreaPayload {
+ payload := iaas.CreateNetworkAreaPayload{
+ Name: utils.Ptr("example-network-area-name"),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ }
+ 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)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ 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: &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",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, nameFlag)
+ }),
+ isValid: false,
+ },
+ {
+ 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) {
+ flagValues[transferNetworkFlag] = testTransferNetwork
+ }),
+ isValid: false,
+ },
+ {
+ 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) {
+ 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,
+ },
+ {
+ 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: "labels missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelFlag)
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ isValid: true,
+ },
+ }
+
+ 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.ApiCreateNetworkAreaRequest
+ }{
+ {
+ 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 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
+ responses *NetworkAreaResponses
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty response",
+ args: args{
+ responses: &NetworkAreaResponses{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty network area",
+ args: args{
+ responses: &NetworkAreaResponses{
+ NetworkArea: iaas.NetworkArea{},
+ },
+ },
+ 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.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
new file mode 100644
index 000000000..e1dbca35e
--- /dev/null
+++ b/internal/cmd/network-area/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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
+)
+
+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 {
+ *globalflags.GlobalFlagModel
+ OrganizationId *string
+ AreaId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", areaIdArg),
+ Short: "Deletes a STACKIT Network Area (SNA)",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Deletes a STACKIT Network Area (SNA) in an organization.",
+ "If the SNA is attached to any projects, the deletion will fail",
+ ),
+ Args: args.SingleArg(areaIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete network area with ID "xxx" in organization with ID "yyy"`,
+ "$ stackit network-area delete 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
+ }
+
+ networkAreaLabel, err := iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, model.AreaId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get network area name: %v", err)
+ networkAreaLabel = model.AreaId
+ }
+
+ 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 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)
+ }
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete network area: %w", err)
+ }
+
+ params.Printer.Outputf("Deleted STACKIT Network Area %q\n", networkAreaLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ areaId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ AreaId: areaId,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteNetworkAreaRequest {
+ return apiClient.DeleteNetworkArea(ctx, *model.OrganizationId, model.AreaId)
+}
diff --git a/internal/cmd/network-area/delete/delete_test.go b/internal/cmd/network-area/delete/delete_test.go
new file mode 100644
index 000000000..1ee322dc3
--- /dev/null
+++ b/internal/cmd/network-area/delete/delete_test.go
@@ -0,0 +1,169 @@
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testOrganizationId = uuid.NewString()
+var testNetworkAreaId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNetworkAreaId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ organizationIdFlag: testOrganizationId,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ OrganizationId: &testOrganizationId,
+ AreaId: testNetworkAreaId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteNetworkAreaRequest)) iaas.ApiDeleteNetworkAreaRequest {
+ request := testClient.DeleteNetworkArea(testCtx, testOrganizationId, testNetworkAreaId)
+ 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: "organization id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, organizationIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "organization id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "organization id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = "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 iaas.ApiDeleteNetworkAreaRequest
+ }{
+ {
+ 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/describe/describe.go b/internal/cmd/network-area/describe/describe.go
new file mode 100644
index 000000000..f51fa40a5
--- /dev/null
+++ b/internal/cmd/network-area/describe/describe.go
@@ -0,0 +1,161 @@
+package describe
+
+import (
+ "context"
+ "errors"
+ "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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ areaIdArg = "AREA_ID"
+ organizationIdFlag = "organization-id"
+ showAttachedProjectsFlag = "show-attached-projects"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ OrganizationId *string
+ AreaId string
+ ShowAttachedProjects bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", areaIdArg),
+ Short: "Shows details of a STACKIT Network Area",
+ Long: "Shows details of a STACKIT Network Area in an organization.",
+ Args: args.SingleArg(areaIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a network area with ID "xxx" in organization with ID "yyy"`,
+ "$ stackit network-area describe xxx --organization-id yyy",
+ ),
+ examples.NewExample(
+ `Show details of a network area with ID "xxx" in organization with ID "yyy" and show attached projects`,
+ "$ stackit network-area describe xxx --organization-id yyy --show-attached-projects",
+ ),
+ examples.NewExample(
+ `Show details of a network area with ID "xxx" in organization with ID "yyy" in JSON format`,
+ "$ stackit network-area describe xxx --organization-id yyy --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 network area: %w", err)
+ }
+
+ var projects []string
+
+ if model.ShowAttachedProjects {
+ projects, err = iaasUtils.ListAttachedProjects(ctx, apiClient, *model.OrganizationId, model.AreaId)
+ if err != nil && errors.Is(err, iaasUtils.ErrItemsNil) {
+ projects = []string{}
+ } else if err != nil {
+ return fmt.Errorf("get attached projects: %w", err)
+ }
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp, projects)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().Bool(showAttachedProjectsFlag, false, "Whether to show attached projects. If a network area has several attached projects, their retrieval may take some time and the output may be extensive.")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ areaId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ AreaId: areaId,
+ ShowAttachedProjects: flags.FlagToBoolValue(p, cmd, showAttachedProjectsFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetNetworkAreaRequest {
+ return apiClient.GetNetworkArea(ctx, *model.OrganizationId, model.AreaId)
+}
+
+func outputResult(p *print.Printer, outputFormat string, networkArea *iaas.NetworkArea, attachedProjects []string) error {
+ if networkArea == nil {
+ return fmt.Errorf("network area is nil")
+ }
+
+ return p.OutputResult(outputFormat, networkArea, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(networkArea.Id))
+ table.AddSeparator()
+ table.AddRow("NAME", utils.PtrString(networkArea.Name))
+ table.AddSeparator()
+ if networkArea.Labels != nil && len(*networkArea.Labels) > 0 {
+ var labels []string
+ for key, value := range *networkArea.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ table.AddSeparator()
+ }
+ if len(attachedProjects) > 0 {
+ table.AddRow("ATTACHED PROJECTS IDS", strings.Join(attachedProjects, "\n"))
+ table.AddSeparator()
+ } else {
+ 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
new file mode 100644
index 000000000..d4cf7379c
--- /dev/null
+++ b/internal/cmd/network-area/describe/describe_test.go
@@ -0,0 +1,220 @@
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testOrganizationId = uuid.NewString()
+var testNetworkAreaId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNetworkAreaId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ organizationIdFlag: testOrganizationId,
+ showAttachedProjectsFlag: "false",
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ OrganizationId: &testOrganizationId,
+ AreaId: testNetworkAreaId,
+ ShowAttachedProjects: false,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetNetworkAreaRequest)) iaas.ApiGetNetworkAreaRequest {
+ request := testClient.GetNetworkArea(testCtx, testOrganizationId, testNetworkAreaId)
+ 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: "organization id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, organizationIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "organization id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "organization id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = "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,
+ },
+ {
+ description: "show attached projects true",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[showAttachedProjectsFlag] = "true"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.ShowAttachedProjects = true
+ }),
+ },
+ }
+
+ 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.ApiGetNetworkAreaRequest
+ }{
+ {
+ 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
+ networkArea *iaas.NetworkArea
+ attachedProjects []string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set networkArea",
+ args: args{
+ networkArea: &iaas.NetworkArea{},
+ },
+ 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.networkArea, tt.args.attachedProjects); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-area/list/list.go b/internal/cmd/network-area/list/list.go
new file mode 100644
index 000000000..a7688648e
--- /dev/null
+++ b/internal/cmd/network-area/list/list.go
@@ -0,0 +1,168 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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-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"
+ 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 (
+ limitFlag = "limit"
+ organizationIdFlag = "organization-id"
+ labelSelectorFlag = "label-selector"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+ OrganizationId *string
+ LabelSelector *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all STACKIT Network Areas (SNA) of an organization",
+ Long: "Lists all STACKIT Network Areas (SNA) of an organization.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all network areas of organization "xxx"`,
+ "$ stackit network-area list --organization-id xxx",
+ ),
+ examples.NewExample(
+ `Lists all network areas of organization "xxx" in JSON format`,
+ "$ stackit network-area list --organization-id xxx --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 network areas of organization "xxx"`,
+ "$ stackit network-area list --organization-id xxx --limit 10",
+ ),
+ examples.NewExample(
+ `Lists all network areas of organization "xxx" which contains the label yyy`,
+ "$ stackit network-area list --organization-id xxx --label-selector 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("list network areas: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ var orgLabel string
+ rmApiClient, err := rmClient.ConfigureClient(params.Printer, params.CliVersion)
+ if err == nil {
+ orgLabel, err = rmUtils.GetOrganizationName(ctx, rmApiClient, *model.OrganizationId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err)
+ orgLabel = *model.OrganizationId
+ } else if orgLabel == "" {
+ orgLabel = *model.OrganizationId
+ }
+ } else {
+ params.Printer.Debug(print.ErrorLevel, "configure resource manager client: %v", err)
+ }
+ params.Printer.Info("No STACKIT Network Areas found for organization %q\n", orgLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag)
+ 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,
+ Limit: limit,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListNetworkAreasRequest {
+ req := apiClient.ListNetworkAreas(ctx, *model.OrganizationId)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, networkAreas []iaas.NetworkArea) error {
+ return p.OutputResult(outputFormat, networkAreas, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "Name", "# Attached Projects")
+
+ for _, networkArea := range networkAreas {
+ table.AddRow(
+ utils.PtrString(networkArea.Id),
+ utils.PtrString(networkArea.Name),
+ utils.PtrString(networkArea.ProjectCount),
+ )
+ table.AddSeparator()
+ }
+
+ 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
new file mode 100644
index 000000000..2524bb8c8
--- /dev/null
+++ b/internal/cmd/network-area/list/list_test.go
@@ -0,0 +1,208 @@
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testOrganizationId = uuid.NewString()
+var testLabelSelector = "foo=bar"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ organizationIdFlag: testOrganizationId,
+ limitFlag: "10",
+ labelSelectorFlag: testLabelSelector,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ OrganizationId: &testOrganizationId,
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreasRequest)) iaas.ApiListNetworkAreasRequest {
+ request := testClient.ListNetworkAreas(testCtx, testOrganizationId)
+ request = request.LabelSelector(testLabelSelector)
+ 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 flag 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "organization id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = "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: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ 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.ApiListNetworkAreasRequest
+ }{
+ {
+ 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
+ networkAreas []iaas.NetworkArea
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty network areas slice",
+ args: args{
+ networkAreas: []iaas.NetworkArea{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty network area in network areas slice",
+ args: args{
+ networkAreas: []iaas.NetworkArea{{}},
+ },
+ 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.networkAreas); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-area/network-range/create/create.go b/internal/cmd/network-area/network-range/create/create.go
new file mode 100644
index 000000000..0502663d8
--- /dev/null
+++ b/internal/cmd/network-area/network-range/create/create.go
@@ -0,0 +1,136 @@
+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/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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+ networkRangeFlag = "network-range"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ OrganizationId *string
+ NetworkAreaId *string
+ NetworkRange *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a network range in a STACKIT Network Area (SNA)",
+ Long: "Creates a network range in a STACKIT Network Area (SNA).",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a network range in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`,
+ `$ 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, 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 a network range for STACKIT Network Area (SNA) %q?", 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 range: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ return fmt.Errorf("empty response from API")
+ }
+
+ networkRange, err := iaasUtils.GetNetworkRangeFromAPIResponse(*model.NetworkRange, resp.Items)
+ if err != nil {
+ return err
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, networkAreaLabel, networkRange)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) ID")
+ cmd.Flags().Var(flags.CIDRFlag(), networkRangeFlag, "Network range to create in CIDR notation")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag, networkRangeFlag)
+ cobra.CheckErr(err)
+}
+
+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),
+ NetworkRange: flags.FlagToStringPointer(p, cmd, networkRangeFlag),
+ }
+
+ 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, model.Region)
+ payload := iaas.CreateNetworkAreaRangePayload{
+ Ipv4: &[]iaas.NetworkRange{
+ {
+ Prefix: model.NetworkRange,
+ },
+ },
+ }
+ return req.CreateNetworkAreaRangePayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, networkAreaLabel string, networkRange iaas.NetworkRange) error {
+ 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
new file mode 100644
index 000000000..913e66b60
--- /dev/null
+++ b/internal/cmd/network-area/network-range/create/create_test.go
@@ -0,0 +1,224 @@
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testOrgId = uuid.NewString()
+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",
+ }
+ 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: utils.Ptr(testOrgId),
+ NetworkAreaId: utils.Ptr(testNetworkAreaId),
+ NetworkRange: utils.Ptr("1.1.1.0/24"),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkAreaRangeRequest)) iaas.ApiCreateNetworkAreaRangeRequest {
+ request := testClient.CreateNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId, testRegion)
+ request = request.CreateNetworkAreaRangePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaRangePayload)) iaas.CreateNetworkAreaRangePayload {
+ payload := iaas.CreateNetworkAreaRangePayload{
+ Ipv4: &[]iaas.NetworkRange{
+ {
+ Prefix: utils.Ptr("1.1.1.0/24"),
+ },
+ },
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "network range missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkRangeFlag)
+ }),
+ isValid: false,
+ },
+ {
+ 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 area id invalid 2",
+ 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network 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.ApiCreateNetworkAreaRangeRequest
+ }{
+ {
+ 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
+ networkAreaLabel string
+ networkRange iaas.NetworkRange
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty network range",
+ args: args{
+ networkRange: iaas.NetworkRange{},
+ },
+ 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.networkRange); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-area/network-range/delete/delete.go b/internal/cmd/network-area/network-range/delete/delete.go
new file mode 100644
index 000000000..84ceb85c7
--- /dev/null
+++ b/internal/cmd/network-area/network-range/delete/delete.go
@@ -0,0 +1,122 @@
+package delete
+
+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"
+ "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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ networkRangeIdArg = "NETWORK_RANGE_ID"
+
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ OrganizationId *string
+ NetworkAreaId *string
+ NetworkRangeId string
+}
+
+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)",
+ Long: "Deletes a network range in a STACKIT Network Area (SNA).",
+ Args: args.SingleArg(networkRangeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete network range with id "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"`,
+ `$ stackit network-area network-range delete xxx --network-area-id yyy --organization-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
+ }
+
+ 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
+ }
+ networkRangeLabel, err := iaasUtils.GetNetworkRangePrefix(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId, model.Region, model.NetworkRangeId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get network range prefix: %v", err)
+ networkRangeLabel = model.NetworkRangeId
+ } else if networkRangeLabel == "" {
+ networkRangeLabel = model.NetworkRangeId
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete network range: %w", err)
+ }
+
+ params.Printer.Info("Deleted network range %q on SNA %q\n", networkRangeLabel, networkAreaLabel)
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) ID")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ networkRangeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag),
+ NetworkRangeId: networkRangeId,
+ }
+
+ 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.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
new file mode 100644
index 000000000..a087681a8
--- /dev/null
+++ b/internal/cmd/network-area/network-range/delete/delete_test.go
@@ -0,0 +1,245 @@
+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/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")
+var testClient = &iaas.APIClient{}
+
+var testOrgId = uuid.NewString()
+var testNetworkAreaId = uuid.NewString()
+var testNetworkRangeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNetworkRangeId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+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: utils.Ptr(testOrgId),
+ NetworkAreaId: utils.Ptr(testNetworkAreaId),
+ NetworkRangeId: testNetworkRangeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteNetworkAreaRangeRequest)) iaas.ApiDeleteNetworkAreaRangeRequest {
+ request := testClient.DeleteNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId, testRegion, testNetworkRangeId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: 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 area id invalid 2",
+ 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "network range id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkRangeIdArg)
+ }),
+ isValid: false,
+ },
+ {
+ description: "network range id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkRangeIdArg] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network range id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkRangeIdArg] = "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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiDeleteNetworkAreaRangeRequest
+ }{
+ {
+ 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/network-range/describe/describe.go b/internal/cmd/network-area/network-range/describe/describe.go
new file mode 100644
index 000000000..1abd718b9
--- /dev/null
+++ b/internal/cmd/network-area/network-range/describe/describe.go
@@ -0,0 +1,120 @@
+package describe
+
+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"
+ "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/spf13/cobra"
+)
+
+const (
+ networkRangeIdArg = "NETWORK_RANGE_ID"
+
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ OrganizationId *string
+ NetworkAreaId *string
+ NetworkRangeId string
+}
+
+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)",
+ Long: "Shows details of a network range in a STACKIT Network Area (SNA).",
+ Args: args.SingleArg(networkRangeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a network range with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"`,
+ `$ stackit network-area network-range describe xxx --network-area-id yyy --organization-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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("describe network range: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) ID")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ networkRangeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag),
+ NetworkRangeId: networkRangeId,
+ }
+
+ 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.Region, model.NetworkRangeId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, networkRange *iaas.NetworkRange) error {
+ if networkRange == nil {
+ return fmt.Errorf("network range is nil")
+ }
+
+ return p.OutputResult(outputFormat, networkRange, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(networkRange.Id))
+ table.AddSeparator()
+ table.AddRow("Network range", utils.PtrString(networkRange.Prefix))
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..ede21b094
--- /dev/null
+++ b/internal/cmd/network-area/network-range/describe/describe_test.go
@@ -0,0 +1,280 @@
+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/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 (
+ projectIdFlag = globalflags.ProjectIdFlag
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testOrgId = uuid.NewString()
+var testNetworkAreaId = uuid.NewString()
+var testNetworkRangeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNetworkRangeId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+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{
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ OrganizationId: utils.Ptr(testOrgId),
+ NetworkAreaId: utils.Ptr(testNetworkAreaId),
+ NetworkRangeId: testNetworkRangeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetNetworkAreaRangeRequest)) iaas.ApiGetNetworkAreaRangeRequest {
+ request := testClient.GetNetworkAreaRange(testCtx, testOrgId, testNetworkAreaId, testRegion, testNetworkRangeId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: 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 area id invalid 2",
+ 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "network range id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkRangeIdArg)
+ }),
+ isValid: false,
+ },
+ {
+ description: "network range id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkRangeIdArg] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network range id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkRangeIdArg] = "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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiGetNetworkAreaRangeRequest
+ }{
+ {
+ 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
+ networkRange *iaas.NetworkRange
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set networkRange",
+ args: args{
+ networkRange: &iaas.NetworkRange{},
+ },
+ 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.networkRange); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-area/network-range/list/list.go b/internal/cmd/network-area/network-range/list/list.go
new file mode 100644
index 000000000..42d57c669
--- /dev/null
+++ b/internal/cmd/network-area/network-range/list/list.go
@@ -0,0 +1,147 @@
+package list
+
+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"
+ "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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ limitFlag = "limit"
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+ OrganizationId *string
+ NetworkAreaId *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all network ranges in a STACKIT Network Area (SNA)",
+ Long: "Lists all network ranges in a STACKIT Network Area (SNA).",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all network ranges in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`,
+ "$ stackit network-area network-range list --network-area-id xxx --organization-id yyy",
+ ),
+ examples.NewExample(
+ `Lists all network ranges in a STACKIT Network Area with ID "xxx" in organization with ID "yyy" in JSON format`,
+ "$ stackit network-area network-range list --network-area-id xxx --organization-id yyy --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 network ranges in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`,
+ "$ stackit network-area network-range list --network-area-id xxx --organization-id yyy --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 network ranges: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ var networkAreaLabel string
+ networkAreaLabel, err = iaasUtils.GetNetworkAreaName(ctx, apiClient, *model.OrganizationId, *model.NetworkAreaId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err)
+ networkAreaLabel = *model.NetworkAreaId
+ }
+ params.Printer.Info("No network ranges found for SNA %q\n", networkAreaLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area (SNA) 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, &cliErr.FlagValidationError{
+ Flag: limitFlag,
+ Details: "must be greater than 0",
+ }
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Limit: limit,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag),
+ }
+
+ 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, model.Region)
+}
+
+func outputResult(p *print.Printer, outputFormat string, networkRanges []iaas.NetworkRange) error {
+ return p.OutputResult(outputFormat, networkRanges, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "Network Range")
+
+ for _, networkRange := range networkRanges {
+ 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
new file mode 100644
index 000000000..80ab8a7c4
--- /dev/null
+++ b/internal/cmd/network-area/network-range/list/list_test.go
@@ -0,0 +1,225 @@
+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")
+var testClient = &iaas.APIClient{}
+var testOrganizationId = uuid.NewString()
+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",
+ }
+ 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: &testOrganizationId,
+ NetworkAreaId: &testNetworkAreaId,
+ Limit: utils.Ptr(int64(10)),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreaRangesRequest)) iaas.ApiListNetworkAreaRangesRequest {
+ request := testClient.ListNetworkAreaRanges(testCtx, testOrganizationId, testNetworkAreaId, 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: "no flag 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "organization id invalid 2",
+ 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = "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.ApiListNetworkAreaRangesRequest
+ }{
+ {
+ 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
+ networkRanges []iaas.NetworkRange
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty network ranges slice",
+ args: args{
+ networkRanges: []iaas.NetworkRange{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty network range in network ranges slice",
+ args: args{
+ networkRanges: []iaas.NetworkRange{{}},
+ },
+ 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.networkRanges); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-area/network-range/network_range.go b/internal/cmd/network-area/network-range/network_range.go
new file mode 100644
index 000000000..adf53b654
--- /dev/null
+++ b/internal/cmd/network-area/network-range/network_range.go
@@ -0,0 +1,33 @@
+package networkranges
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/network-range/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/network-range/delete"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "network-range",
+ Aliases: []string{"range"},
+ Short: "Provides functionality for network ranges in STACKIT Network Areas",
+ Long: "Provides functionality for network ranges in STACKIT Network Areas.",
+ 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/network-area/network_area.go b/internal/cmd/network-area/network_area.go
new file mode 100644
index 000000000..ff09d229e
--- /dev/null
+++ b/internal/cmd/network-area/network_area.go
@@ -0,0 +1,40 @@
+package networkarea
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/delete"
+ "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/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: "network-area",
+ Short: "Provides functionality for STACKIT Network Area (SNA)",
+ Long: "Provides functionality for 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(delete.NewCmd(params))
+ cmd.AddCommand(describe.NewCmd(params))
+ cmd.AddCommand(list.NewCmd(params))
+ cmd.AddCommand(networkrange.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..63a8c6348
--- /dev/null
+++ b/internal/cmd/network-area/region/create/create.go
@@ -0,0 +1,194 @@
+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/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 (
+ 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Create network area region")
+ _, err = wait.CreateNetworkAreaRegionWaitHandler(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for network area region creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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().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, region, networkAreaLabel string, regionalArea iaas.RegionalArea) error {
+ return p.OutputResult(outputFormat, regionalArea, func() error {
+ p.Outputf("Create region configuration for SNA %q.\nRegion: %s\n", 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..cf92c59c3
--- /dev/null
+++ b/internal/cmd/network-area/region/create/create_test.go
@@ -0,0 +1,308 @@
+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-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"
+ 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
+ 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/region/delete/delete.go b/internal/cmd/network-area/region/delete/delete.go
new file mode 100644
index 000000000..bbbd44262
--- /dev/null
+++ b/internal/cmd/network-area/region/delete/delete.go
@@ -0,0 +1,128 @@
+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"
+ "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-sdk-go/services/iaas"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Delete network area region")
+ _, err = wait.DeleteNetworkAreaRegionWaitHandler(ctx, apiClient, model.OrganizationId, model.NetworkAreaId, model.Region).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for network area region deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..919e86cb8
--- /dev/null
+++ b/internal/cmd/network-area/region/delete/delete_test.go
@@ -0,0 +1,168 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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..131d0f031
--- /dev/null
+++ b/internal/cmd/network-area/region/describe/describe.go
@@ -0,0 +1,170 @@
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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..ea4beee77
--- /dev/null
+++ b/internal/cmd/network-area/region/describe/describe_test.go
@@ -0,0 +1,215 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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..c74f79848
--- /dev/null
+++ b/internal/cmd/network-area/region/list/list.go
@@ -0,0 +1,154 @@
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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..f3cbc6ec8
--- /dev/null
+++ b/internal/cmd/network-area/region/list/list_test.go
@@ -0,0 +1,222 @@
+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-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"
+)
+
+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..8b68195ff
--- /dev/null
+++ b/internal/cmd/network-area/region/update/update.go
@@ -0,0 +1,164 @@
+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-sdk-go/services/iaas"
+)
+
+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..90535d384
--- /dev/null
+++ b/internal/cmd/network-area/region/update/update_test.go
@@ -0,0 +1,266 @@
+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-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"
+ 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
new file mode 100644
index 000000000..72c9fe006
--- /dev/null
+++ b/internal/cmd/network-area/route/create/create.go
@@ -0,0 +1,305 @@
+package create
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+
+ "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"
+ "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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+ // 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
+ DestinationV4 *string
+ DestinationV6 *string
+ NexthopV4 *string
+ NexthopV6 *string
+ NexthopBlackhole *bool
+ NexthopInternet *bool
+ Labels *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a static route in a STACKIT Network Area (SNA)",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Creates a static route in a STACKIT Network Area (SNA).",
+ "This command is currently asynchonous only due to limitations in the waiting functionality of the SDK. This will be updated in a future release.",
+ ),
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `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 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, 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 a static route for STACKIT Network Area (SNA) %q?", 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 static route: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ return fmt.Errorf("empty response from API")
+ }
+
+ 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(params.Printer, model.OutputFormat, networkAreaLabel, route)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+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().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...)
+
+ cmd.MarkFlagsOneRequired(destinationFlags...)
+ cmd.MarkFlagsOneRequired(nexthopFlags...)
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag)
+ cobra.CheckErr(err)
+}
+
+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)
+
+ 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 prefix := flags.FlagToStringPointer(p, cmd, prefixFlag); prefix != nil {
+ var err error
+ destinationV4, destinationV6, err = parseDestination(*prefix)
+ if err != nil {
+ 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, 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{
+ Items: &[]iaas.Route{
+ {
+ Destination: &iaas.RouteDestination{
+ DestinationCIDRv4: destinationV4,
+ DestinationCIDRv6: destinationV6,
+ },
+ Nexthop: &iaas.RouteNexthop{
+ NexthopIPv4: nexthopIPv4,
+ NexthopIPv6: nexthopIPv6,
+ NexthopBlackhole: nexthopBlackhole,
+ NexthopInternet: nexthopInternet,
+ },
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ },
+ },
+ }
+ return req.CreateNetworkAreaRoutePayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, networkAreaLabel string, route iaas.Route) error {
+ 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
+ })
+}
diff --git a/internal/cmd/network-area/route/create/create_test.go b/internal/cmd/network-area/route/create/create_test.go
new file mode 100644
index 000000000..497c31a66
--- /dev/null
+++ b/internal/cmd/network-area/route/create/create_test.go
@@ -0,0 +1,299 @@
+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"
+ testDestinationCIDRv4 = "1.1.1.0/24"
+ testNexthopIPv4 = "1.1.1.1"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testOrgId = uuid.NewString()
+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,
+ destinationFlag: testDestinationCIDRv4,
+ nexthopIPv4Flag: testNexthopIPv4,
+ }
+ 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: utils.Ptr(testOrgId),
+ NetworkAreaId: utils.Ptr(testNetworkAreaId),
+ DestinationV4: utils.Ptr(testDestinationCIDRv4),
+ NexthopV4: utils.Ptr(testNexthopIPv4),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateNetworkAreaRouteRequest)) iaas.ApiCreateNetworkAreaRouteRequest {
+ request := testClient.CreateNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRegion)
+ request = request.CreateNetworkAreaRoutePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateNetworkAreaRoutePayload)) iaas.CreateNetworkAreaRoutePayload {
+ payload := iaas.CreateNetworkAreaRoutePayload{
+ Items: &[]iaas.Route{
+ {
+ 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),
+ },
+ },
+ },
+ },
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+
+ {
+ description: "next hop missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, nexthopIPv4Flag)
+ }),
+ isValid: false,
+ },
+ {
+ 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 area id invalid 2",
+ 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "destination missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, destinationFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "destinationFlag invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[destinationFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "destinationFlag invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[destinationFlag] = "invalid-destinationFlag"
+ }),
+ isValid: false,
+ },
+ {
+ description: "optional labels is 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: "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) {
+ 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.ApiCreateNetworkAreaRouteRequest
+ }{
+ {
+ 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.ApiCreateNetworkAreaRouteRequest) {
+ *request = (*request).CreateNetworkAreaRoutePayload(fixturePayload(func(payload *iaas.CreateNetworkAreaRoutePayload) {
+ (*payload.Items)[0].Labels = utils.Ptr(map[string]interface{}{"key": "value"})
+ }))
+ }),
+ },
+ }
+
+ 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
+ networkAreaLabel string
+ route iaas.Route
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty route",
+ args: args{
+ route: iaas.Route{},
+ },
+ 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.route); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-area/route/delete/delete.go b/internal/cmd/network-area/route/delete/delete.go
new file mode 100644
index 000000000..dbad67c03
--- /dev/null
+++ b/internal/cmd/network-area/route/delete/delete.go
@@ -0,0 +1,114 @@
+package delete
+
+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"
+ "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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ routeIdArg = "ROUTE_ID"
+
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ OrganizationId *string
+ NetworkAreaId *string
+ RouteId string
+}
+
+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)",
+ Long: "Deletes a static route in a STACKIT Network Area (SNA).",
+ Args: args.SingleArg(routeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete a static route with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"`,
+ "$ stackit network-area route delete xxx --organization-id zzz --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
+ }
+
+ 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 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete static route: %w", err)
+ }
+
+ params.Printer.Info("Deleted static route %q on SNA %q\n", model.RouteId, networkAreaLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area ID")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ routeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag),
+ RouteId: routeId,
+ }
+
+ 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.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
new file mode 100644
index 000000000..6352be04a
--- /dev/null
+++ b/internal/cmd/network-area/route/delete/delete_test.go
@@ -0,0 +1,245 @@
+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/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")
+var testClient = &iaas.APIClient{}
+
+var testOrgId = uuid.NewString()
+var testNetworkAreaId = uuid.NewString()
+var testRouteId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testRouteId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+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: utils.Ptr(testOrgId),
+ NetworkAreaId: utils.Ptr(testNetworkAreaId),
+ RouteId: testRouteId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteNetworkAreaRouteRequest)) iaas.ApiDeleteNetworkAreaRouteRequest {
+ request := testClient.DeleteNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRegion, testRouteId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: 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 area id invalid 2",
+ 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "route id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, routeIdArg)
+ }),
+ isValid: false,
+ },
+ {
+ description: "route id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[routeIdArg] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "route id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[routeIdArg] = "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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiDeleteNetworkAreaRouteRequest
+ }{
+ {
+ 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/route/describe/describe.go b/internal/cmd/network-area/route/describe/describe.go
new file mode 100644
index 000000000..2a7e7e4f1
--- /dev/null
+++ b/internal/cmd/network-area/route/describe/describe.go
@@ -0,0 +1,160 @@
+package describe
+
+import (
+ "context"
+ "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/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/spf13/cobra"
+)
+
+const (
+ routeIdArg = "ROUTE_ID"
+
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ OrganizationId *string
+ NetworkAreaId *string
+ RouteId string
+}
+
+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)",
+ Long: "Shows details of a static route in a STACKIT Network Area (SNA).",
+ Args: args.SingleArg(routeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a static route with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"`,
+ `$ stackit network-area route describe xxx --network-area-id yyy --organization-id zzz`,
+ ),
+ examples.NewExample(
+ `Show details of a static route with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz" in JSON format`,
+ `$ stackit network-area route describe xxx --network-area-id yyy --organization-id zzz --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("describe static route: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area ID")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ routeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag),
+ RouteId: routeId,
+ }
+
+ 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.Region, model.RouteId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, route iaas.Route) error {
+ return p.OutputResult(outputFormat, route, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(route.Id))
+ table.AddSeparator()
+ 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 {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddSeparator()
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..3923e2b26
--- /dev/null
+++ b/internal/cmd/network-area/route/describe/describe_test.go
@@ -0,0 +1,279 @@
+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/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")
+var testClient = &iaas.APIClient{}
+
+var testOrgId = uuid.NewString()
+var testNetworkAreaId = uuid.NewString()
+var testRouteId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testRouteId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+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: utils.Ptr(testOrgId),
+ NetworkAreaId: utils.Ptr(testNetworkAreaId),
+ RouteId: testRouteId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetNetworkAreaRouteRequest)) iaas.ApiGetNetworkAreaRouteRequest {
+ request := testClient.GetNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRegion, testRouteId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: 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: "network area id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkAreaIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "network route id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, routeIdArg)
+ }),
+ isValid: false,
+ },
+ {
+ description: "network route id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[routeIdArg] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network route id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[routeIdArg] = "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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiGetNetworkAreaRouteRequest
+ }{
+ {
+ 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
+ route iaas.Route
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty route",
+ args: args{
+ route: iaas.Route{},
+ },
+ 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.route); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-area/route/list/list.go b/internal/cmd/network-area/route/list/list.go
new file mode 100644
index 000000000..d85ac49db
--- /dev/null
+++ b/internal/cmd/network-area/route/list/list.go
@@ -0,0 +1,176 @@
+package list
+
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ limitFlag = "limit"
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+ OrganizationId *string
+ NetworkAreaId *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all static routes in a STACKIT Network Area (SNA)",
+ Long: "Lists all static routes in a STACKIT Network Area (SNA).",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all static routes in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`,
+ "$ stackit network-area route list --network-area-id xxx --organization-id yyy",
+ ),
+ examples.NewExample(
+ `Lists all static routes in a STACKIT Network Area with ID "xxx" in organization with ID "yyy" in JSON format`,
+ "$ stackit network-area route list --network-area-id xxx --organization-id yyy --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 static routes in a STACKIT Network Area with ID "xxx" in organization with ID "yyy"`,
+ "$ stackit network-area route list --network-area-id xxx --organization-id yyy --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 static routes: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ var networkAreaLabel string
+ 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
+ }
+ params.Printer.Info("No static routes found for STACKIT Network Area %q\n", networkAreaLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "STACKIT Network Area 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, &cliErr.FlagValidationError{
+ Flag: limitFlag,
+ Details: "must be greater than 0",
+ }
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Limit: limit,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag),
+ }
+
+ 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, model.Region)
+}
+
+func outputResult(p *print.Printer, outputFormat string, routes []iaas.Route) error {
+ return p.OutputResult(outputFormat, routes, func() error {
+ table := tables.NewTable()
+ 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.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
new file mode 100644
index 000000000..f40f9bafe
--- /dev/null
+++ b/internal/cmd/network-area/route/list/list_test.go
@@ -0,0 +1,243 @@
+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")
+var testClient = &iaas.APIClient{}
+var testOrganizationId = uuid.NewString()
+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",
+ }
+ 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: &testOrganizationId,
+ NetworkAreaId: &testNetworkAreaId,
+ Limit: utils.Ptr(int64(10)),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListNetworkAreaRoutesRequest)) iaas.ApiListNetworkAreaRoutesRequest {
+ request := testClient.ListNetworkAreaRoutes(testCtx, testOrganizationId, testNetworkAreaId, 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: "no flag 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "organization id invalid 2",
+ 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = "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.ApiListNetworkAreaRoutesRequest
+ }{
+ {
+ 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
+ routes []iaas.Route
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty route slice",
+ args: args{
+ routes: []iaas.Route{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty route in routes slice",
+ args: args{
+ routes: []iaas.Route{{}},
+ },
+ 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(&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 {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-area/route/routes.go b/internal/cmd/network-area/route/routes.go
new file mode 100644
index 000000000..f6d2b3656
--- /dev/null
+++ b/internal/cmd/network-area/route/routes.go
@@ -0,0 +1,34 @@
+package route
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/route/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/route/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network-area/route/describe"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "route",
+ Short: "Provides functionality for static routes in STACKIT Network Areas",
+ Long: "Provides functionality for static routes in STACKIT Network Areas.",
+ 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/network-area/route/update/update.go b/internal/cmd/network-area/route/update/update.go
new file mode 100644
index 000000000..c20c86601
--- /dev/null
+++ b/internal/cmd/network-area/route/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"
+ 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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ routeIdArg = "ROUTE_ID"
+
+ organizationIdFlag = "organization-id"
+ networkAreaIdFlag = "network-area-id"
+
+ labelFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ OrganizationId *string
+ NetworkAreaId *string
+ RouteId string
+ Labels *map[string]string
+}
+
+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)",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Updates a static route in a STACKIT Network Area (SNA).",
+ "This command is currently asynchonous only due to limitations in the waiting functionality of the SDK. This will be updated in a future release.",
+ ),
+ Args: args.SingleArg(routeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Updates the label(s) of a static route with ID "xxx" in a STACKIT Network Area with ID "yyy" in organization with ID "zzz"`,
+ "$ stackit network-area route update xxx --labels key=value,foo=bar --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
+ }
+
+ // 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("create static route: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, networkAreaLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+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().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")
+
+ err := flags.MarkFlagsRequired(cmd, organizationIdFlag, networkAreaIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ routeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ labels := flags.FlagToStringToStringPointer(p, cmd, labelFlag)
+
+ if labels == nil {
+ return nil, &cliErr.EmptyUpdateError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ NetworkAreaId: flags.FlagToStringPointer(p, cmd, networkAreaIdFlag),
+ RouteId: routeId,
+ Labels: labels,
+ }
+
+ 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.Region, model.RouteId)
+
+ payload := iaas.UpdateNetworkAreaRoutePayload{
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+ req = req.UpdateNetworkAreaRoutePayload(payload)
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat, networkAreaLabel string, route iaas.Route) error {
+ 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
+ })
+}
diff --git a/internal/cmd/network-area/route/update/update_test.go b/internal/cmd/network-area/route/update/update_test.go
new file mode 100644
index 000000000..855d36513
--- /dev/null
+++ b/internal/cmd/network-area/route/update/update_test.go
@@ -0,0 +1,313 @@
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testOrgId = uuid.NewString()
+var testNetworkAreaId = uuid.NewString()
+var testRouteId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testRouteId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+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",
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdateNetworkAreaRoutePayload)) iaas.UpdateNetworkAreaRoutePayload {
+ payload := iaas.UpdateNetworkAreaRoutePayload{
+ Labels: &map[string]interface{}{
+ "value": "key",
+ },
+ }
+
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func fixturePayloadAsStringMap() map[string]string {
+ payload := fixturePayload()
+ labelsMap := make(map[string]string)
+ for k, v := range *payload.Labels {
+ if value, ok := v.(string); ok {
+ labelsMap[k] = value
+ }
+ }
+ return labelsMap
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ payload := fixturePayloadAsStringMap()
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ OrganizationId: utils.Ptr(testOrgId),
+ NetworkAreaId: utils.Ptr(testNetworkAreaId),
+ RouteId: testRouteId,
+ Labels: utils.Ptr(payload),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdateNetworkAreaRouteRequest)) iaas.ApiUpdateNetworkAreaRouteRequest {
+ request := testClient.UpdateNetworkAreaRoute(testCtx, testOrgId, testNetworkAreaId, testRegion, testRouteId)
+ request = request.UpdateNetworkAreaRoutePayload(fixturePayload())
+ 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",
+ 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 area id invalid 2",
+ 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 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network area id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "route id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, routeIdArg)
+ }),
+ isValid: false,
+ },
+ {
+ description: "route id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[routeIdArg] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "route id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[routeIdArg] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "labels missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelFlag)
+ }),
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiUpdateNetworkAreaRouteRequest
+ }{
+ {
+ 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
+ networkAreaLabel string
+ route iaas.Route
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty route",
+ args: args{
+ route: iaas.Route{},
+ },
+ 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.route); (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
new file mode 100644
index 000000000..98f778bc3
--- /dev/null
+++ b/internal/cmd/network-area/update/update.go
@@ -0,0 +1,250 @@
+package update
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "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"
+ "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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ areaIdArg = "AREA_ID"
+
+ 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"
+ // 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
+ // 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
+ // 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(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", areaIdArg),
+ Short: "Updates a STACKIT Network Area (SNA)",
+ Long: "Updates a STACKIT Network Area (SNA) in an organization.",
+ Args: args.SingleArg(areaIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update network area with ID "xxx" in organization with ID "yyy" with new name "network-area-1-new"`,
+ "$ stackit network-area update xxx --organization-id yyy --name network-area-1-new",
+ ),
+ ),
+ 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
+ }
+
+ var orgLabel string
+ rmApiClient, err := rmClient.ConfigureClient(params.Printer, params.CliVersion)
+ if err == nil {
+ orgLabel, err = rmUtils.GetOrganizationName(ctx, rmApiClient, *model.OrganizationId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get organization name: %v", err)
+ orgLabel = *model.OrganizationId
+ } else if orgLabel == "" {
+ orgLabel = *model.OrganizationId
+ }
+ } else {
+ params.Printer.Debug(print.ErrorLevel, "configure resource manager client: %v", 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update network area: %w", err)
+ }
+
+ 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)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(nameFlag, "n", "", "Network area name")
+ cmd.Flags().Var(flags.UUIDFlag(), organizationIdFlag, "Organization ID")
+ cmd.Flags().StringSlice(dnsNameServersFlag, nil, "List of DNS name server IPs")
+ 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,...'")
+
+ 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)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ areaId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ OrganizationId: flags.FlagToStringPointer(p, cmd, organizationIdFlag),
+ AreaId: areaId,
+ DnsNameServers: flags.FlagToStringSlicePointer(p, cmd, dnsNameServersFlag),
+ DefaultPrefixLength: flags.FlagToInt64Pointer(p, cmd, defaultPrefixLengthFlag),
+ MaxPrefixLength: flags.FlagToInt64Pointer(p, cmd, maxPrefixLengthFlag),
+ MinPrefixLength: flags.FlagToInt64Pointer(p, cmd, minPrefixLengthFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+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.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: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+
+ return req.PartialUpdateNetworkAreaPayload(payload)
+}
+
+func buildRequestNetworkAreaRegion(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateNetworkAreaRegionRequest {
+ req := apiClient.UpdateNetworkAreaRegion(ctx, *model.OrganizationId, model.AreaId, model.Region)
+
+ payload := iaas.UpdateNetworkAreaRegionPayload{
+ Ipv4: &iaas.UpdateRegionalAreaIPv4{
+ DefaultNameservers: model.DnsNameServers,
+ DefaultPrefixLen: model.DefaultPrefixLength,
+ MaxPrefixLen: model.MaxPrefixLength,
+ MinPrefixLen: model.MinPrefixLength,
+ },
+ }
+
+ 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
new file mode 100644
index 000000000..e6963c929
--- /dev/null
+++ b/internal/cmd/network-area/update/update_test.go
@@ -0,0 +1,507 @@
+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"
+
+ "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 = "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()
+ testAreaId = uuid.NewString()
+
+ testDnsNameservers = []string{"1.1.1.0", "1.1.2.0"}
+)
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testAreaId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.RegionFlag: testRegion,
+
+ nameFlag: testName,
+ organizationIdFlag: testOrgId,
+ labelFlag: "key=value",
+ }
+ 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,
+ },
+ Name: utils.Ptr(testName),
+ OrganizationId: utils.Ptr(testOrgId),
+ AreaId: testAreaId,
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiPartialUpdateNetworkAreaRequest)) iaas.ApiPartialUpdateNetworkAreaRequest {
+ request := testClient.PartialUpdateNetworkArea(testCtx, testOrgId, testAreaId)
+ request = request.PartialUpdateNetworkAreaPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.PartialUpdateNetworkAreaPayload)) iaas.PartialUpdateNetworkAreaPayload {
+ payload := iaas.PartialUpdateNetworkAreaPayload{
+ Name: utils.Ptr(testName),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ }
+ 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 {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "with deprecated flags",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ 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 = utils.Ptr(testDnsNameservers)
+ model.DefaultPrefixLength = utils.Ptr(testDefaultPrefixLength)
+ model.MaxPrefixLength = utils.Ptr(testMaxPrefixLength)
+ model.MinPrefixLength = utils.Ptr(testMinPrefixLength)
+ }),
+ },
+
+ {
+ description: "no values",
+ argValues: []string{},
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "org id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, organizationIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "org id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "org id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[organizationIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "area id missing",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "area id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[areaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "area id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "invalid-uuid"
+ }),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[areaIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "labels missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelFlag)
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ isValid: true,
+ },
+ }
+
+ 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 iaas.ApiPartialUpdateNetworkAreaRequest
+ }{
+ {
+ 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 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
+ responses NetworkAreaResponses
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty network area",
+ args: args{
+ responses: NetworkAreaResponses{
+ NetworkArea: iaas.NetworkArea{},
+ RegionalArea: 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.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
new file mode 100644
index 000000000..5939d712d
--- /dev/null
+++ b/internal/cmd/network-interface/create/create.go
@@ -0,0 +1,219 @@
+package create
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+
+ "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"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ networkIdFlag = "network-id"
+ allowedAddressesFlag = "allowed-addresses"
+ ipv4Flag = "ipv4"
+ ipv6Flag = "ipv6"
+ labelFlag = "labels"
+ nameFlag = "name"
+ securityGroupsFlag = "security-groups"
+ nicSecurityFlag = "nic-security"
+
+ nameRegex = `^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`
+ maxNameLength = 63
+ securityGroupsRegex = `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`
+ securityGroupLength = 36
+ defaultNicSecurityFlag = true
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ NetworkId string
+ AllowedAddresses *[]iaas.AllowedAddressesInner
+ Ipv4 *string
+ Ipv6 *string
+ Labels *map[string]string
+ Name *string // <= 63 characters + regex ^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$
+ NicSecurity *bool
+ 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(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a network interface",
+ Long: "Creates a network interface.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a network interface for network with ID "xxx"`,
+ `$ stackit network-interface create --network-id xxx`,
+ ),
+ examples.NewExample(
+ `Create a network interface with allowed addresses, labels, a name, security groups and nic security enabled for network with ID "xxx"`,
+ `$ 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, 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 network interface 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 network interface: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.ProjectId, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID")
+ cmd.Flags().StringSlice(allowedAddressesFlag, nil, "List of allowed IPs")
+ cmd.Flags().StringP(ipv4Flag, "i", "", "IPv4 address")
+ cmd.Flags().StringP(ipv6Flag, "s", "", "IPv6 address")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'")
+ cmd.Flags().StringP(nameFlag, "n", "", "Network interface name")
+ cmd.Flags().BoolP(nicSecurityFlag, "b", defaultNicSecurityFlag, "If this is set to false, then no security groups will apply to this network interface.")
+ cmd.Flags().StringSlice(securityGroupsFlag, nil, "List of security groups")
+
+ err := flags.MarkFlagsRequired(cmd, networkIdFlag)
+ 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{}
+ }
+
+ allowedAddresses := flags.FlagToStringSlicePointer(p, cmd, allowedAddressesFlag)
+ var allowedAddressesInner []iaas.AllowedAddressesInner
+ if allowedAddresses != nil && len(*allowedAddresses) > 0 {
+ allowedAddressesInner = make([]iaas.AllowedAddressesInner, len(*allowedAddresses))
+ for i, address := range *allowedAddresses {
+ allowedAddressesInner[i].String = &address
+ }
+ }
+
+ // check name length <= 63 and regex must apply
+ name := flags.FlagToStringPointer(p, cmd, nameFlag)
+ if name != nil {
+ if len(*name) > maxNameLength {
+ return nil, &errors.FlagValidationError{
+ Flag: nameFlag,
+ Details: fmt.Sprintf("name %s is too long (maximum length is %d characters)", *name, maxNameLength),
+ }
+ }
+ nameRegex := regexp.MustCompile(nameRegex)
+ if !nameRegex.MatchString(*name) {
+ return nil, &errors.FlagValidationError{
+ Flag: nameFlag,
+ Details: fmt.Sprintf("name %s didn't match the required regex expression %s", *name, nameRegex),
+ }
+ }
+ }
+
+ // check security groups size and regex
+ securityGroups := flags.FlagToStringSlicePointer(p, cmd, securityGroupsFlag)
+ if securityGroups != nil && len(*securityGroups) > 0 {
+ securityGroupsRegex := regexp.MustCompile(securityGroupsRegex)
+ // iterate over them
+ for _, value := range *securityGroups {
+ if len(value) != securityGroupLength {
+ return nil, &errors.FlagValidationError{
+ Flag: securityGroupsFlag,
+ Details: fmt.Sprintf("security groups uuid %s does not match (must be %d characters long)", value, securityGroupLength),
+ }
+ }
+ if !securityGroupsRegex.MatchString(value) {
+ return nil, &errors.FlagValidationError{
+ Flag: securityGroupsFlag,
+ Details: fmt.Sprintf("security groups uuid %s didn't match the required regex expression %s", value, securityGroupsRegex),
+ }
+ }
+ }
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag),
+ Ipv4: flags.FlagToStringPointer(p, cmd, ipv4Flag),
+ Ipv6: flags.FlagToStringPointer(p, cmd, ipv6Flag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ Name: name,
+ NicSecurity: flags.FlagToBoolPointer(p, cmd, nicSecurityFlag),
+ SecurityGroups: securityGroups,
+ }
+
+ if allowedAddresses != nil {
+ model.AllowedAddresses = utils.Ptr(allowedAddressesInner)
+ }
+
+ 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.Region, model.NetworkId)
+
+ payload := iaas.CreateNicPayload{
+ AllowedAddresses: model.AllowedAddresses,
+ Ipv4: model.Ipv4,
+ Ipv6: model.Ipv6,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ Name: model.Name,
+ NicSecurity: model.NicSecurity,
+ SecurityGroups: model.SecurityGroups,
+ }
+ return req.CreateNicPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, projectId string, nic *iaas.NIC) error {
+ if nic == nil {
+ return fmt.Errorf("nic is empty")
+ }
+ 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
new file mode 100644
index 000000000..5ebe70d2b
--- /dev/null
+++ b/internal/cmd/network-interface/create/create_test.go
@@ -0,0 +1,268 @@
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+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{
+ 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",
+ ipv6Flag: "2001:0db8:85a3:08d3::0370:7344",
+ labelFlag: "key=value",
+ nameFlag: "testNic",
+ nicSecurityFlag: "true",
+ securityGroupsFlag: testSecurityGroup,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ 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")),
+ }
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ NetworkId: testNetworkId,
+ AllowedAddresses: utils.Ptr(allowedAddresses),
+ Ipv4: utils.Ptr("1.2.3.4"),
+ Ipv6: utils.Ptr("2001:0db8:85a3:08d3::0370:7344"),
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ Name: utils.Ptr("testNic"),
+ NicSecurity: utils.Ptr(true),
+ SecurityGroups: utils.Ptr([]string{testSecurityGroup}),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateNicRequest)) iaas.ApiCreateNicRequest {
+ request := testClient.CreateNic(testCtx, testProjectId, testRegion, testNetworkId)
+ request = request.CreateNicPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateNicPayload)) iaas.CreateNicPayload {
+ 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")),
+ }
+ payload := iaas.CreateNicPayload{
+ AllowedAddresses: utils.Ptr(allowedAddresses),
+ Ipv4: utils.Ptr("1.2.3.4"),
+ Ipv6: utils.Ptr("2001:0db8:85a3:08d3::0370:7344"),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ Name: utils.Ptr("testNic"),
+ NicSecurity: utils.Ptr(true),
+ SecurityGroups: utils.Ptr([]string{testSecurityGroup}),
+ }
+ 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: "network id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "network id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "allowed addresses missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, allowedAddressesFlag)
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.AllowedAddresses = nil
+ }),
+ isValid: true,
+ },
+ {
+ description: "name to long",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = "verylongstringwith66characterstotestthenameregexwithinthisunittest"
+ }),
+ isValid: false,
+ },
+ {
+ description: "name invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = "test?"
+ }),
+ isValid: false,
+ },
+ {
+ description: "name empty string invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group uuid to short",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[securityGroupsFlag] = "d61a8564-c8dd-4ffb-bc15-143e7d0c85e"
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group uuid invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[securityGroupsFlag] = "d61a8564-c8dd-4ffb-bc15-143e7d0c85e?"
+ }),
+ 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.ApiCreateNicRequest
+ }{
+ {
+ 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
+ projectId string
+ nic *iaas.NIC
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty nic",
+ args: args{
+ nic: &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 := outputResult(p, tt.args.outputFormat, tt.args.projectId, tt.args.nic); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-interface/delete/delete.go b/internal/cmd/network-interface/delete/delete.go
new file mode 100644
index 000000000..de6d972cb
--- /dev/null
+++ b/internal/cmd/network-interface/delete/delete.go
@@ -0,0 +1,103 @@
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ nicIdArg = "NIC_ID"
+
+ networkIdFlag = "network-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ NetworkId string
+ NicId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", nicIdArg),
+ Short: "Deletes a network interface",
+ Long: "Deletes a network interface.",
+ Args: args.SingleArg(nicIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete network interface with nic id "xxx" and network ID "yyy"`,
+ `$ stackit network-interface delete xxx --network-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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete network interface: %w", err)
+ }
+
+ params.Printer.Info("Deleted network interface %q\n", model.NicId)
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID")
+
+ err := flags.MarkFlagsRequired(cmd, networkIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ nicId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag),
+ NicId: nicId,
+ }
+
+ 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.Region, model.NetworkId, model.NicId)
+ return req
+}
diff --git a/internal/cmd/ske/credentials/rotate/rotate_test.go b/internal/cmd/network-interface/delete/delete_test.go
similarity index 77%
rename from internal/cmd/ske/credentials/rotate/rotate_test.go
rename to internal/cmd/network-interface/delete/delete_test.go
index efe1e3709..e541c34a7 100644
--- a/internal/cmd/ske/credentials/rotate/rotate_test.go
+++ b/internal/cmd/network-interface/delete/delete_test.go
@@ -1,30 +1,35 @@
-package rotate
+package delete
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/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/ske"
+ "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{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &ske.APIClient{}
+var testClient = &iaas.APIClient{}
+
var testProjectId = uuid.NewString()
-var testClusterName = "cluster"
+var testNetworkId = uuid.NewString()
+var testNicId = uuid.NewString()
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
- testClusterName,
+ testNicId,
}
for _, mod := range mods {
mod(argValues)
@@ -34,7 +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,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ networkIdFlag: testNetworkId,
}
for _, mod := range mods {
mod(flagValues)
@@ -47,8 +54,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
- ClusterName: testClusterName,
+ NetworkId: testNetworkId,
+ NicId: testNicId,
}
for _, mod := range mods {
mod(model)
@@ -56,8 +65,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *ske.ApiTriggerRotateCredentialsRequest)) ske.ApiTriggerRotateCredentialsRequest {
- request := testClient.TriggerRotateCredentials(testCtx, testProjectId, testClusterName)
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteNicRequest)) iaas.ApiDeleteNicRequest {
+ request := testClient.DeleteNic(testCtx, testProjectId, testRegion, testNetworkId, testNicId)
for _, mod := range mods {
mod(&request)
}
@@ -81,52 +90,45 @@ func TestParseInput(t *testing.T) {
},
{
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",
+ description: "network id missing",
argValues: fixtureArgValues(),
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
- delete(flagValues, projectIdFlag)
+ delete(flagValues, networkIdFlag)
}),
isValid: false,
},
{
- description: "project id invalid 1",
+ description: "network id invalid 1",
argValues: fixtureArgValues(),
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
- flagValues[projectIdFlag] = ""
+ flagValues[networkIdFlag] = ""
}),
isValid: false,
},
{
- description: "project id invalid 2",
+ description: "network id invalid 2",
argValues: fixtureArgValues(),
flagValues: fixtureFlagValues(func(flagValues map[string]string) {
- flagValues[projectIdFlag] = "invalid-uuid"
+ flagValues[networkIdFlag] = "invalid-uuid"
}),
isValid: false,
},
+ {
+ description: "nic argument missing",
+ argValues: []string{},
+ 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)
@@ -147,7 +149,7 @@ func TestParseInput(t *testing.T) {
if !tt.isValid {
return
}
- t.Fatalf("error validating args: %v", err)
+ t.Fatalf("error parsing args: %v", err)
}
err = cmd.ValidateRequiredFlags()
@@ -181,7 +183,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest ske.ApiTriggerRotateCredentialsRequest
+ expectedRequest iaas.ApiDeleteNicRequest
}{
{
description: "base",
diff --git a/internal/cmd/network-interface/describe/describe.go b/internal/cmd/network-interface/describe/describe.go
new file mode 100644
index 000000000..52a47532a
--- /dev/null
+++ b/internal/cmd/network-interface/describe/describe.go
@@ -0,0 +1,167 @@
+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/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"
+)
+
+const (
+ nicIdArg = "NIC_ID"
+
+ networkIdFlag = "network-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ NetworkId string
+ NicId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", nicIdArg),
+ Short: "Describes a network interface",
+ Long: "Describes a network interface.",
+ Args: args.SingleArg(nicIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Describes network interface with nic id "xxx" and network ID "yyy"`,
+ `$ stackit network-interface describe xxx --network-id yyy`,
+ ),
+ examples.NewExample(
+ `Describes network interface with nic id "xxx" and network ID "yyy" in JSON format`,
+ `$ stackit network-interface describe xxx --network-id yyy --output-format json`,
+ ),
+ examples.NewExample(
+ `Describes network interface with nic id "xxx" and network ID "yyy" in yaml format`,
+ `$ stackit network-interface describe xxx --network-id yyy --output-format yaml`,
+ ),
+ ),
+ 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("describe network interface: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID")
+
+ err := flags.MarkFlagsRequired(cmd, networkIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ nicId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag),
+ NicId: nicId,
+ }
+
+ 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.Region, model.NetworkId, model.NicId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, nic *iaas.NIC) error {
+ if nic == nil {
+ return fmt.Errorf("nic is empty")
+ }
+ return p.OutputResult(outputFormat, nic, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(nic.Id))
+ table.AddSeparator()
+ table.AddRow("NETWORK ID", utils.PtrString(nic.NetworkId))
+ table.AddSeparator()
+ if nic.Name != nil {
+ table.AddRow("NAME", utils.PtrString(nic.Name))
+ table.AddSeparator()
+ }
+ if nic.Ipv4 != nil {
+ table.AddRow("IPV4", utils.PtrString(nic.Ipv4))
+ table.AddSeparator()
+ }
+ if nic.Ipv6 != nil {
+ table.AddRow("IPV6", utils.PtrString(nic.Ipv6))
+ table.AddSeparator()
+ }
+ table.AddRow("MAC", utils.PtrString(nic.Mac))
+ table.AddSeparator()
+ table.AddRow("NIC SECURITY", utils.PtrString(nic.NicSecurity))
+ if nic.AllowedAddresses != nil && len(*nic.AllowedAddresses) > 0 {
+ var allowedAddresses []string
+ for _, value := range *nic.AllowedAddresses {
+ allowedAddresses = append(allowedAddresses, *value.String)
+ }
+ table.AddSeparator()
+ table.AddRow("ALLOWED ADDRESSES", strings.Join(allowedAddresses, "\n"))
+ }
+ if nic.Labels != nil && len(*nic.Labels) > 0 {
+ var labels []string
+ for key, value := range *nic.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddSeparator()
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ }
+ table.AddSeparator()
+ table.AddRow("STATUS", utils.PtrString(nic.Status))
+ table.AddSeparator()
+ table.AddRow("TYPE", utils.PtrString(nic.Type))
+ if nic.SecurityGroups != nil && len(*nic.SecurityGroups) > 0 {
+ table.AddSeparator()
+ table.AddRow("SECURITY GROUPS", strings.Join(*nic.SecurityGroups, "\n"))
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..be6d7f317
--- /dev/null
+++ b/internal/cmd/network-interface/describe/describe_test.go
@@ -0,0 +1,242 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testNetworkId = uuid.NewString()
+var testNicId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNicId,
+ }
+ 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,
+ networkIdFlag: testNetworkId,
+ }
+ 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,
+ },
+ NetworkId: testNetworkId,
+ NicId: testNicId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetNicRequest)) iaas.ApiGetNicRequest {
+ request := testClient.GetNic(testCtx, testProjectId, testRegion, testNetworkId, testNicId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "network id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "network id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "nic argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiGetNicRequest
+ }{
+ {
+ 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
+ nic *iaas.NIC
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty nic",
+ args: args{
+ nic: &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 := outputResult(p, tt.args.outputFormat, tt.args.nic); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network-interface/list/list.go b/internal/cmd/network-interface/list/list.go
new file mode 100644
index 000000000..21c41a260
--- /dev/null
+++ b/internal/cmd/network-interface/list/list.go
@@ -0,0 +1,169 @@
+package list
+
+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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ limitFlag = "limit"
+ labelSelectorFlag = "label-selector"
+ networkIdFlag = "network-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+ LabelSelector *string
+ NetworkId string
+}
+
+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(
+ examples.NewExample(
+ `Lists all network interfaces with network ID "xxx"`,
+ `$ stackit network-interface list --network-id xxx`,
+ ),
+ examples.NewExample(
+ `Lists all network interfaces with network ID "xxx" which contains the label xxx`,
+ `$ stackit network-interface list --network-id xxx --label-selector xxx`,
+ ),
+ examples.NewExample(
+ `Lists all network interfaces with network ID "xxx" in JSON format`,
+ `$ stackit network-interface list --network-id xxx --output-format json`,
+ ),
+ examples.NewExample(
+ `Lists up to 10 network interfaces with network ID "xxx"`,
+ `$ stackit network-interface list --network-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 network interfaces: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ 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
+ }
+ params.Printer.Info("No network interfaces found for network %q\n", networkLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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(), 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, _ []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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(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)
+ }
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, nics []iaas.NIC) error {
+ return p.OutputResult(outputFormat, nics, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "NAME", "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.NicSecurity),
+ utils.PtrString(nic.Device),
+ utils.PtrString(nic.Ipv4),
+ utils.PtrString(nic.Status),
+ utils.PtrString(nic.Type),
+ )
+ table.AddSeparator()
+ }
+
+ 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
new file mode 100644
index 000000000..d04c1d9b7
--- /dev/null
+++ b/internal/cmd/network-interface/list/list_test.go
@@ -0,0 +1,204 @@
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testNetworkId = uuid.NewString()
+var testLabelSelector = "label"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ networkIdFlag: testNetworkId,
+ limitFlag: "10",
+ labelSelectorFlag: testLabelSelector,
+ }
+ 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,
+ },
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ NetworkId: testNetworkId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListNicsRequest)) iaas.ApiListNicsRequest {
+ request := testClient.ListNics(testCtx, testProjectId, testRegion, testNetworkId)
+ request = request.LabelSelector(testLabelSelector)
+ 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 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: "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: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ 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.ApiListNicsRequest
+ }{
+ {
+ 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
+ nics []iaas.NIC
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.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
new file mode 100644
index 000000000..d9cb6214d
--- /dev/null
+++ b/internal/cmd/network-interface/network-interface.go
@@ -0,0 +1,33 @@
+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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "network-interface",
+ Short: "Provides functionality for network interfaces",
+ Long: "Provides functionality for network interfaces.",
+ 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(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
new file mode 100644
index 000000000..eeae44774
--- /dev/null
+++ b/internal/cmd/network-interface/update/update.go
@@ -0,0 +1,209 @@
+package update
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+
+ "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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ nicIdArg = "NIC_ID"
+
+ networkIdFlag = "network-id"
+ allowedAddressesFlag = "allowed-addresses"
+ labelFlag = "labels"
+ nameFlag = "name"
+ securityGroupsFlag = "security-groups"
+ nicSecurityFlag = "nic-security"
+
+ nameRegex = `^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$`
+ maxNameLength = 63
+ securityGroupsRegex = `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`
+ securityGroupLength = 36
+ defaultNicSecurityFlag = true
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ NicId string
+ NetworkId string
+ AllowedAddresses *[]iaas.AllowedAddressesInner
+ Labels *map[string]string
+ Name *string // <= 63 characters + regex ^[A-Za-z0-9]+((-|_|\s|\.)[A-Za-z0-9]+)*$
+ NicSecurity *bool
+ 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(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", nicIdArg),
+ Short: "Updates a network interface",
+ Long: "Updates a network interface.",
+ Args: args.SingleArg(nicIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Updates a network interface with nic id "xxx" and network-id "yyy" to new allowed addresses "1.1.1.1,8.8.8.8,9.9.9.9" and new labels "key=value,key2=value2"`,
+ `$ stackit network-interface update xxx --network-id yyy --allowed-addresses "1.1.1.1,8.8.8.8,9.9.9.9" --labels key=value,key2=value2`,
+ ),
+ examples.NewExample(
+ `Updates a network interface with nic id "xxx" and network-id "yyy" with new name "nic-name-new"`,
+ `$ stackit network-interface update xxx --network-id yyy --name nic-name-new`,
+ ),
+ examples.NewExample(
+ `Updates a network interface with nic id "xxx" and network-id "yyy" to include the security group "zzz"`,
+ `$ stackit network-interface update xxx --network-id yyy --security-groups 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update network interface: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.ProjectId, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID")
+ cmd.Flags().StringSlice(allowedAddressesFlag, nil, "List of allowed IPs")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'")
+ cmd.Flags().StringP(nameFlag, "n", "", "Network interface name")
+ cmd.Flags().BoolP(nicSecurityFlag, "b", defaultNicSecurityFlag, "If this is set to false, then no security groups will apply to this network interface.")
+ cmd.Flags().StringSlice(securityGroupsFlag, nil, "List of security groups")
+
+ err := flags.MarkFlagsRequired(cmd, networkIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ nicId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ allowedAddresses := flags.FlagToStringSlicePointer(p, cmd, allowedAddressesFlag)
+ var allowedAddressesInner []iaas.AllowedAddressesInner
+ if allowedAddresses != nil && len(*allowedAddresses) > 0 {
+ allowedAddressesInner = make([]iaas.AllowedAddressesInner, len(*allowedAddresses))
+ for i, address := range *allowedAddresses {
+ allowedAddressesInner[i].String = &address
+ }
+ }
+
+ // check name length and regex must apply
+ name := flags.FlagToStringPointer(p, cmd, nameFlag)
+ if name != nil {
+ if len(*name) > maxNameLength {
+ return nil, &errors.FlagValidationError{
+ Flag: nameFlag,
+ Details: fmt.Sprintf("name %s is too long (maximum length is %d characters)", *name, maxNameLength),
+ }
+ }
+ nameRegex := regexp.MustCompile(nameRegex)
+ if !nameRegex.MatchString(*name) {
+ return nil, &errors.FlagValidationError{
+ Flag: nameFlag,
+ Details: fmt.Sprintf("name %s didn't match the required regex expression %s", *name, nameRegex),
+ }
+ }
+ }
+
+ // check security groups size and regex
+ securityGroups := flags.FlagToStringSlicePointer(p, cmd, securityGroupsFlag)
+ if securityGroups != nil && len(*securityGroups) > 0 {
+ securityGroupsRegex := regexp.MustCompile(securityGroupsRegex)
+ // iterate over them
+ for _, value := range *securityGroups {
+ if len(value) != securityGroupLength {
+ return nil, &errors.FlagValidationError{
+ Flag: securityGroupsFlag,
+ Details: fmt.Sprintf("security groups uuid %s does not match (must be %d characters long)", value, securityGroupLength),
+ }
+ }
+ if !securityGroupsRegex.MatchString(value) {
+ return nil, &errors.FlagValidationError{
+ Flag: securityGroupsFlag,
+ Details: fmt.Sprintf("security groups uuid %s didn't match the required regex expression %s", value, securityGroupsRegex),
+ }
+ }
+ }
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ NicId: nicId,
+ NetworkId: flags.FlagToStringValue(p, cmd, networkIdFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ Name: name,
+ NicSecurity: flags.FlagToBoolPointer(p, cmd, nicSecurityFlag),
+ SecurityGroups: securityGroups,
+ }
+
+ if allowedAddresses != nil {
+ model.AllowedAddresses = utils.Ptr(allowedAddressesInner)
+ }
+
+ 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.Region, model.NetworkId, model.NicId)
+
+ payload := iaas.UpdateNicPayload{
+ AllowedAddresses: model.AllowedAddresses,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ Name: model.Name,
+ NicSecurity: model.NicSecurity,
+ SecurityGroups: model.SecurityGroups,
+ }
+ return req.UpdateNicPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, projectId string, nic *iaas.NIC) error {
+ if nic == nil {
+ return fmt.Errorf("nic is empty")
+ }
+ 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
new file mode 100644
index 000000000..7eba7d62d
--- /dev/null
+++ b/internal/cmd/network-interface/update/update_test.go
@@ -0,0 +1,335 @@
+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/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testNetworkId = uuid.NewString()
+var testNicId = uuid.NewString()
+var testSecurityGroup = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNicId,
+ }
+ 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,
+
+ networkIdFlag: testNetworkId,
+ allowedAddressesFlag: "1.1.1.1,8.8.8.8,9.9.9.9",
+ labelFlag: "key=value",
+ nameFlag: "testNic",
+ nicSecurityFlag: "true",
+ securityGroupsFlag: testSecurityGroup,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ 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")),
+ }
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ NetworkId: testNetworkId,
+ AllowedAddresses: utils.Ptr(allowedAddresses),
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ Name: utils.Ptr("testNic"),
+ NicSecurity: utils.Ptr(true),
+ SecurityGroups: utils.Ptr([]string{testSecurityGroup}),
+ NicId: testNicId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdateNicRequest)) iaas.ApiUpdateNicRequest {
+ request := testClient.UpdateNic(testCtx, testProjectId, testRegion, testNetworkId, testNicId)
+ request = request.UpdateNicPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdateNicPayload)) iaas.UpdateNicPayload {
+ 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")),
+ }
+ payload := iaas.UpdateNicPayload{
+ AllowedAddresses: utils.Ptr(allowedAddresses),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ Name: utils.Ptr("testNic"),
+ NicSecurity: utils.Ptr(true),
+ SecurityGroups: utils.Ptr([]string{testSecurityGroup}),
+ }
+ 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: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "network id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "network id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "allowed addresses missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, allowedAddressesFlag)
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.AllowedAddresses = nil
+ }),
+ isValid: true,
+ },
+ {
+ description: "name to long",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = "verylongstringwith66characterstotestthenameregexwithinthisunittest"
+ }),
+ isValid: false,
+ },
+ {
+ description: "name invalid",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = "test?"
+ }),
+ isValid: false,
+ },
+ {
+ description: "name empty string invalid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group uuid to short",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[securityGroupsFlag] = "d61a8564-c8dd-4ffb-bc15-143e7d0c85e"
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group uuid invalid",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[securityGroupsFlag] = "d61a8564-c8dd-4ffb-bc15-143e7d0c85e?"
+ }),
+ isValid: false,
+ },
+ {
+ description: "nic argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiUpdateNicRequest
+ }{
+ {
+ 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
+ projectId string
+ nic *iaas.NIC
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty nic",
+ args: args{
+ nic: &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 := outputResult(p, tt.args.outputFormat, tt.args.projectId, tt.args.nic); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network/create/create.go b/internal/cmd/network/create/create.go
new file mode 100644
index 000000000..fee9123ce
--- /dev/null
+++ b/internal/cmd/network/create/create.go
@@ -0,0 +1,313 @@
+package create
+
+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"
+ "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/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 (
+ nameFlag = "name"
+ ipv4DnsNameServersFlag = "ipv4-dns-name-servers"
+ ipv4PrefixLengthFlag = "ipv4-prefix-length"
+ ipv4PrefixFlag = "ipv4-prefix"
+ ipv4GatewayFlag = "ipv4-gateway"
+ ipv6DnsNameServersFlag = "ipv6-dns-name-servers"
+ ipv6PrefixLengthFlag = "ipv6-prefix-length"
+ ipv6PrefixFlag = "ipv6-prefix"
+ ipv6GatewayFlag = "ipv6-gateway"
+ nonRoutedFlag = "non-routed"
+ noIpv4GatewayFlag = "no-ipv4-gateway"
+ noIpv6GatewayFlag = "no-ipv6-gateway"
+ labelFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Name *string
+ IPv4DnsNameServers *[]string
+ IPv4PrefixLength *int64
+ IPv4Prefix *string
+ IPv4Gateway *string
+ IPv6DnsNameServers *[]string
+ IPv6PrefixLength *int64
+ IPv6Prefix *string
+ IPv6Gateway *string
+ NonRouted bool
+ NoIPv4Gateway bool
+ NoIPv6Gateway bool
+ Labels *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a network",
+ Long: "Creates a network.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a network with name "network-1"`,
+ `$ stackit network create --name network-1`,
+ ),
+ examples.NewExample(
+ `Create a non-routed network with name "network-1"`,
+ `$ stackit network create --name network-1 --non-routed`,
+ ),
+ examples.NewExample(
+ `Create a network with name "network-1" and no gateway`,
+ `$ stackit network create --name network-1 --no-ipv4-gateway`,
+ ),
+ examples.NewExample(
+ `Create a network with name "network-1" and labels "key=value,key1=value1"`,
+ `$ stackit network create --name network-1 --labels key=value,key1=value1`,
+ ),
+ 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 --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"`,
+ ),
+ ),
+ 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 network 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 network : %w", err)
+ }
+
+ 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(params.Printer)
+ s.Start("Creating network")
+ _, err = wait.CreateNetworkWaitHandler(ctx, apiClient, model.ProjectId, model.Region, networkId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for network creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(nameFlag, "n", "", "Network name")
+ cmd.Flags().StringSlice(ipv4DnsNameServersFlag, []string{}, "List of DNS name servers for IPv4. Nameservers cannot be defined for routed networks")
+ cmd.Flags().Int64(ipv4PrefixLengthFlag, 0, "The prefix length of the IPv4 network")
+ cmd.Flags().String(ipv4PrefixFlag, "", "The IPv4 prefix of the network (CIDR)")
+ cmd.Flags().String(ipv4GatewayFlag, "", "The IPv4 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway")
+ cmd.Flags().StringSlice(ipv6DnsNameServersFlag, []string{}, "List of DNS name servers for IPv6. Nameservers cannot be defined for routed networks")
+ cmd.Flags().Int64(ipv6PrefixLengthFlag, 0, "The prefix length of the IPv6 network")
+ cmd.Flags().String(ipv6PrefixFlag, "", "The IPv6 prefix of the network (CIDR)")
+ 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(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().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, _ []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ IPv4DnsNameServers: flags.FlagToStringSlicePointer(p, cmd, ipv4DnsNameServersFlag),
+ 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),
+ IPv6Gateway: flags.FlagToStringPointer(p, cmd, ipv6GatewayFlag),
+ NonRouted: flags.FlagToBoolValue(p, cmd, nonRoutedFlag),
+ NoIPv4Gateway: flags.FlagToBoolValue(p, cmd, noIpv4GatewayFlag),
+ NoIPv6Gateway: flags.FlagToBoolValue(p, cmd, noIpv6GatewayFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ }
+
+ // 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, 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 {
+ ipv6Network.CreateNetworkIPv6WithPrefix.Gateway = iaas.NewNullableString(nil)
+ } else if model.IPv6Gateway != nil {
+ 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.IPv4Prefix != nil {
+ ipv4Network = &iaas.CreateNetworkIPv4{
+ CreateNetworkIPv4WithPrefix: &iaas.CreateNetworkIPv4WithPrefix{
+ Prefix: model.IPv4Prefix,
+ Nameservers: model.IPv4DnsNameServers,
+ },
+ }
+
+ if model.NoIPv4Gateway {
+ ipv4Network.CreateNetworkIPv4WithPrefix.Gateway = iaas.NewNullableString(nil)
+ } else if model.IPv4Gateway != nil {
+ ipv4Network.CreateNetworkIPv4WithPrefix.Gateway = iaas.NewNullableString(model.IPv4Gateway)
+ }
+ } else if model.IPv4PrefixLength != nil {
+ ipv4Network = &iaas.CreateNetworkIPv4{
+ CreateNetworkIPv4WithPrefixLength: &iaas.CreateNetworkIPv4WithPrefixLength{
+ PrefixLength: model.IPv4PrefixLength,
+ Nameservers: model.IPv4DnsNameServers,
+ },
+ }
+ }
+
+ payload := iaas.CreateNetworkPayload{
+ Name: model.Name,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ Routed: utils.Ptr(!model.NonRouted),
+ Ipv4: ipv4Network,
+ Ipv6: ipv6Network,
+ }
+
+ return req.CreateNetworkPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, network *iaas.Network) error {
+ if network == nil {
+ return fmt.Errorf("network cannot be nil")
+ }
+ 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.Id))
+ return nil
+ })
+}
diff --git a/internal/cmd/network/create/create_test.go b/internal/cmd/network/create/create_test.go
new file mode 100644
index 000000000..dbb2ea6d3
--- /dev/null
+++ b/internal/cmd/network/create/create_test.go
@@ -0,0 +1,646 @@
+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"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+
+ 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 (
+ testIPv4NameServers = []string{"1.1.1.0", "1.1.2.0"}
+ testIPv6NameServers = []string{"2001:4860:4860::8888", "2001:4860:4860::8844"}
+)
+
+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,
+
+ nameFlag: testNetworkName,
+ nonRoutedFlag: strconv.FormatBool(testNonRouted),
+ labelFlag: "key=value",
+ }
+ 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)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ Name: utils.Ptr(testNetworkName),
+ NonRouted: testNonRouted,
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ 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, testRegion)
+ request = request.CreateNetworkPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateNetworkRequest)) iaas.ApiCreateNetworkRequest {
+ request := testClient.CreateNetwork(testCtx, testProjectId, testRegion)
+ request = request.CreateNetworkPayload(iaas.CreateNetworkPayload{
+ Name: utils.Ptr(testNetworkName),
+ Routed: utils.Ptr(true),
+ })
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateNetworkPayload)) iaas.CreateNetworkPayload {
+ payload := iaas.CreateNetworkPayload{
+ Name: utils.Ptr("example-network-name"),
+ Routed: utils.Ptr(true),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ }
+ 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 {
+ 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: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ nameFlag: testNetworkName,
+ },
+ isValid: true,
+ expectedModel: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ Name: utils.Ptr(testNetworkName),
+ },
+ },
+ {
+ description: "name missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, nameFlag)
+ }),
+ isValid: false,
+ },
+ {
+ 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: "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: fixtureInputModelWithPrefix(func(model *inputModel) {
+ model.IPv6PrefixLength = nil
+ model.IPv6Prefix = nil
+ model.IPv6DnsNameServers = nil
+ model.IPv6Gateway = nil
+ }),
+ },
+ {
+ 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: fixtureInputModelWithPrefix(func(model *inputModel) {
+ model.IPv4PrefixLength = nil
+ model.IPv4Prefix = nil
+ model.IPv4DnsNameServers = nil
+ model.IPv4Gateway = nil
+ }),
+ },
+ {
+ 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[noIpv4GatewayFlag] = "true"
+ delete(flagValues, ipv4GatewayFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.NoIPv4Gateway = true
+ model.IPv4Gateway = nil
+ }),
+ },
+ {
+ description: "use ipv6 gateway nil",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[noIpv6GatewayFlag] = "true"
+ delete(flagValues, ipv6GatewayFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.NoIPv6Gateway = true
+ 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) {
+ flagValues[nonRoutedFlag] = "true"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.NonRouted = true
+ }),
+ },
+ {
+ description: "labels missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelFlag)
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ isValid: true,
+ },
+ }
+
+ 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 iaas.ApiCreateNetworkRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "only name in payload",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ 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(testNetworkName),
+ NonRouted: true,
+ },
+ expectedRequest: testClient.CreateNetwork(testCtx, testProjectId, testRegion).CreateNetworkPayload(iaas.CreateNetworkPayload{
+ Name: utils.Ptr(testNetworkName),
+ Routed: utils.Ptr(false),
+ }),
+ },
+ {
+ description: "use ipv4 dns servers and prefix length",
+ 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)),
+ },
+ 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),
+ })
+ }),
+ },
+ {
+ 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 dns servers, prefix and gateway",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ 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, 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),
+ }),
+ },
+ }
+ 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),
+ cmp.AllowUnexported(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ projectLabel string
+ network *iaas.Network
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty network",
+ args: args{
+ network: &iaas.Network{},
+ },
+ 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.network); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network/delete/delete.go b/internal/cmd/network/delete/delete.go
new file mode 100644
index 000000000..1f3b00b95
--- /dev/null
+++ b/internal/cmd/network/delete/delete.go
@@ -0,0 +1,123 @@
+package delete
+
+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/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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ networkIdArg = "NETWORK_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ NetworkId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", networkIdArg),
+ Short: "Deletes a network",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Deletes a network.",
+ "If the network is still in use, the deletion will fail",
+ ),
+ Args: args.SingleArg(networkIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete network with ID "xxx"`,
+ "$ stackit network 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
+ }
+
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete network: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting network")
+ _, err = wait.DeleteNetworkWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.NetworkId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for network deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Deleted"
+ if model.Async {
+ operationState = "Triggered deletion of"
+ }
+ params.Printer.Info("%s network %q\n", operationState, networkLabel)
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ networkId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ NetworkId: networkId,
+ }
+
+ 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.Region, model.NetworkId)
+}
diff --git a/internal/cmd/network/delete/delete_test.go b/internal/cmd/network/delete/delete_test.go
new file mode 100644
index 000000000..76627b697
--- /dev/null
+++ b/internal/cmd/network/delete/delete_test.go
@@ -0,0 +1,175 @@
+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")
+var testClient = &iaas.APIClient{}
+var testNetworkId = uuid.NewString()
+var testProjectId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNetworkId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ NetworkId: testNetworkId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteNetworkRequest)) iaas.ApiDeleteNetworkRequest {
+ request := testClient.DeleteNetwork(testCtx, testProjectId, testRegion, testNetworkId)
+ 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: "network id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "network 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 iaas.ApiDeleteNetworkRequest
+ }{
+ {
+ 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/describe/describe.go b/internal/cmd/network/describe/describe.go
new file mode 100644
index 000000000..ab81a8c48
--- /dev/null
+++ b/internal/cmd/network/describe/describe.go
@@ -0,0 +1,196 @@
+package describe
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+)
+
+const (
+ networkIdArg = "NETWORK_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ NetworkId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", networkIdArg),
+ Short: "Shows details of a network",
+ Long: "Shows details of a network.",
+ Args: args.SingleArg(networkIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a network with ID "xxx"`,
+ "$ stackit network describe xxx",
+ ),
+ examples.NewExample(
+ `Show details of a network with ID "xxx" in JSON format`,
+ "$ stackit network 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 network: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ networkId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ NetworkId: networkId,
+ }
+
+ 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.Region, model.NetworkId)
+}
+
+func outputResult(p *print.Printer, outputFormat string, network *iaas.Network) error {
+ if network == nil {
+ return fmt.Errorf("network cannot be nil")
+ }
+ 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()
+ }
+ }
+
+ // 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.Id))
+ table.AddSeparator()
+ table.AddRow("NAME", utils.PtrString(network.Name))
+ table.AddSeparator()
+ table.AddRow("STATE", utils.PtrString(network.Status))
+ table.AddSeparator()
+
+ if publicIp != nil {
+ table.AddRow("PUBLIC IP", *publicIp)
+ table.AddSeparator()
+ }
+
+ routed := false
+ if network.Routed != nil {
+ routed = *network.Routed
+ }
+
+ table.AddRow("ROUTED", routed)
+ table.AddSeparator()
+
+ 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, ", "))
+ }
+ table.AddSeparator()
+
+ if ipv6Gateway != nil {
+ table.AddRow("IPv6 GATEWAY", *ipv6Gateway)
+ table.AddSeparator()
+ }
+
+ if len(ipv6Nameservers) > 0 {
+ table.AddRow("IPv6 NAME SERVERS", strings.Join(ipv6Nameservers, ", "))
+ table.AddSeparator()
+ }
+ if len(ipv6Prefixes) > 0 {
+ table.AddRow("IPv6 PREFIXES", strings.Join(ipv6Prefixes, ", "))
+ table.AddSeparator()
+ }
+ if network.Labels != nil && len(*network.Labels) > 0 {
+ var labels []string
+ for key, value := range *network.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ table.AddSeparator()
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..14fa618e4
--- /dev/null
+++ b/internal/cmd/network/describe/describe_test.go
@@ -0,0 +1,230 @@
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testNetworkId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNetworkId,
+ }
+ 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,
+ },
+ NetworkId: testNetworkId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetNetworkRequest)) iaas.ApiGetNetworkRequest {
+ request := testClient.GetNetwork(testCtx, testProjectId, testRegion, testNetworkId)
+ 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: "network id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "network 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 iaas.ApiGetNetworkRequest
+ }{
+ {
+ 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
+ network *iaas.Network
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty network",
+ args: args{
+ network: &iaas.Network{},
+ },
+ 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(&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 {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network/list/list.go b/internal/cmd/network/list/list.go
new file mode 100644
index 000000000..3b1fabea5
--- /dev/null
+++ b/internal/cmd/network/list/list.go
@@ -0,0 +1,172 @@
+package list
+
+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"
+ "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"
+)
+
+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 networks of a project",
+ Long: "Lists all network of a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all networks`,
+ "$ stackit network list",
+ ),
+ examples.NewExample(
+ `Lists all networks in JSON format`,
+ "$ stackit network list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 networks`,
+ "$ stackit network list --limit 10",
+ ),
+ examples.NewExample(
+ `Lists all networks which contains the label xxx`,
+ "$ stackit network list --label-selector 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 networks: %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
+ } else if projectLabel == "" {
+ projectLabel = model.ProjectId
+ }
+ params.Printer.Info("No networks found for project %q\n", projectLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListNetworksRequest {
+ req := apiClient.ListNetworks(ctx, model.ProjectId, model.Region)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, networks []iaas.Network) error {
+ return p.OutputResult(outputFormat, networks, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "NAME", "STATUS", "PUBLIC IP", "PREFIXES", "ROUTED")
+
+ for _, network := range networks {
+ 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
+ }
+
+ table.AddRow(
+ utils.PtrString(network.Id),
+ utils.PtrString(network.Name),
+ utils.PtrString(network.Status),
+ publicIp,
+ prefixes,
+ routed,
+ )
+ 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
new file mode 100644
index 000000000..67e90a2b4
--- /dev/null
+++ b/internal/cmd/network/list/list_test.go
@@ -0,0 +1,210 @@
+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"
+ testLabelSelector = "foo=bar"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.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",
+ labelSelectorFlag: testLabelSelector,
+ }
+ 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,
+ },
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListNetworksRequest)) iaas.ApiListNetworksRequest {
+ request := testClient.ListNetworks(testCtx, testProjectId, testRegion)
+ request = request.LabelSelector(testLabelSelector)
+ 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 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: "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: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(inputModel *inputModel) {
+ inputModel.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ 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.ApiListNetworksRequest
+ }{
+ {
+ 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
+ networks []iaas.Network
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty network",
+ args: args{
+ networks: []iaas.Network{
+ {},
+ },
+ },
+ 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.networks); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/network/network.go b/internal/cmd/network/network.go
new file mode 100644
index 000000000..eb7c6ece7
--- /dev/null
+++ b/internal/cmd/network/network.go
@@ -0,0 +1,34 @@
+package network
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network/describe"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "network",
+ Short: "Provides functionality for networks",
+ Long: "Provides functionality for networks.",
+ 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/network/update/update.go b/internal/cmd/network/update/update.go
new file mode 100644
index 000000000..13e7e5acc
--- /dev/null
+++ b/internal/cmd/network/update/update.go
@@ -0,0 +1,206 @@
+package update
+
+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"
+ "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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ networkIdArg = "NETWORK_ID"
+
+ nameFlag = "name"
+ ipv4DnsNameServersFlag = "ipv4-dns-name-servers"
+ ipv4GatewayFlag = "ipv4-gateway"
+ ipv6DnsNameServersFlag = "ipv6-dns-name-servers"
+ ipv6GatewayFlag = "ipv6-gateway"
+ noIpv4GatewayFlag = "no-ipv4-gateway"
+ noIpv6GatewayFlag = "no-ipv6-gateway"
+ labelFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ NetworkId string
+ Name *string
+ IPv4DnsNameServers *[]string
+ IPv4Gateway *string
+ IPv6DnsNameServers *[]string
+ IPv6Gateway *string
+ NoIPv4Gateway bool
+ NoIPv6Gateway bool
+ Labels *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", networkIdArg),
+ Short: "Updates a network",
+ Long: "Updates a network.",
+ Args: args.SingleArg(networkIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update network with ID "xxx" with new name "network-1-new"`,
+ `$ stackit network update xxx --name network-1-new`,
+ ),
+ examples.NewExample(
+ `Update network with ID "xxx" with no gateway`,
+ `$ stackit network update --no-ipv4-gateway`,
+ ),
+ examples.NewExample(
+ `Update IPv4 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 --ipv4-dns-name-servers "2.2.2.2" --ipv4-gateway "10.1.2.3"`,
+ ),
+ examples.NewExample(
+ `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"`,
+ ),
+ ),
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("update network area: %w", err)
+ }
+ networkId := model.NetworkId
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Updating network")
+ _, err = wait.UpdateNetworkWaitHandler(ctx, apiClient, model.ProjectId, model.Region, networkId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for network update: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Updated"
+ if model.Async {
+ operationState = "Triggered update of"
+ }
+ params.Printer.Info("%s network %q\n", operationState, networkLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(nameFlag, "n", "", "Network name")
+ cmd.Flags().StringSlice(ipv4DnsNameServersFlag, nil, "List of DNS name servers IPv4. Nameservers cannot be defined for routed networks")
+ cmd.Flags().String(ipv4GatewayFlag, "", "The IPv4 gateway of a network. If not specified, the first IP of the network will be assigned as the gateway")
+ cmd.Flags().StringSlice(ipv6DnsNameServersFlag, nil, "List of DNS name servers for IPv6. Nameservers cannot be defined for routed networks")
+ 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().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a network. E.g. '--labels key1=value1,key2=value2,...'")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ networkId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ NetworkId: networkId,
+ IPv4DnsNameServers: flags.FlagToStringSlicePointer(p, cmd, ipv4DnsNameServersFlag),
+ IPv4Gateway: flags.FlagToStringPointer(p, cmd, ipv4GatewayFlag),
+ IPv6DnsNameServers: flags.FlagToStringSlicePointer(p, cmd, ipv6DnsNameServersFlag),
+ IPv6Gateway: flags.FlagToStringPointer(p, cmd, ipv6GatewayFlag),
+ NoIPv4Gateway: flags.FlagToBoolValue(p, cmd, noIpv4GatewayFlag),
+ NoIPv6Gateway: flags.FlagToBoolValue(p, cmd, noIpv6GatewayFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ }
+
+ 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.Region, model.NetworkId)
+ var payloadIPv4 *iaas.UpdateNetworkIPv4Body
+ var payloadIPv6 *iaas.UpdateNetworkIPv6Body
+
+ if model.IPv6DnsNameServers != nil || model.NoIPv6Gateway || model.IPv6Gateway != nil {
+ payloadIPv6 = &iaas.UpdateNetworkIPv6Body{
+ Nameservers: model.IPv6DnsNameServers,
+ }
+
+ if model.NoIPv6Gateway {
+ payloadIPv6.Gateway = iaas.NewNullableString(nil)
+ } else if model.IPv6Gateway != nil {
+ payloadIPv6.Gateway = iaas.NewNullableString(model.IPv6Gateway)
+ }
+ }
+
+ if model.IPv4DnsNameServers != nil || model.NoIPv4Gateway || model.IPv4Gateway != nil {
+ payloadIPv4 = &iaas.UpdateNetworkIPv4Body{
+ Nameservers: model.IPv4DnsNameServers,
+ }
+
+ if model.NoIPv4Gateway {
+ payloadIPv4.Gateway = iaas.NewNullableString(nil)
+ } else if model.IPv4Gateway != nil {
+ payloadIPv4.Gateway = iaas.NewNullableString(model.IPv4Gateway)
+ }
+ }
+
+ payload := iaas.PartialUpdateNetworkPayload{
+ Name: model.Name,
+ Ipv4: payloadIPv4,
+ Ipv6: payloadIPv6,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+
+ return req.PartialUpdateNetworkPayload(payload)
+}
diff --git a/internal/cmd/network/update/update_test.go b/internal/cmd/network/update/update_test.go
new file mode 100644
index 000000000..87b533c0a
--- /dev/null
+++ b/internal/cmd/network/update/update_test.go
@@ -0,0 +1,327 @@
+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/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testNetworkId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testNetworkId,
+ }
+ 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: "example-network-name",
+ 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",
+ }
+ 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: utils.Ptr("example-network-name"),
+ NetworkId: testNetworkId,
+ IPv4DnsNameServers: utils.Ptr([]string{"1.1.1.0", "1.1.2.0"}),
+ IPv4Gateway: utils.Ptr("10.1.2.3"),
+ IPv6DnsNameServers: utils.Ptr([]string{"2001:4860:4860::8888", "2001:4860:4860::8844"}),
+ IPv6Gateway: utils.Ptr("2001:4860:4860::8888"),
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiPartialUpdateNetworkRequest)) iaas.ApiPartialUpdateNetworkRequest {
+ request := testClient.PartialUpdateNetwork(testCtx, testProjectId, testRegion, testNetworkId)
+ request = request.PartialUpdateNetworkPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.PartialUpdateNetworkPayload)) iaas.PartialUpdateNetworkPayload {
+ payload := iaas.PartialUpdateNetworkPayload{
+ Name: utils.Ptr("example-network-name"),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ 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")),
+ },
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "required only",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, ipv4DnsNameServersFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.IPv4DnsNameServers = 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: "network id invalid 1",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "network id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "use dns servers and gateway",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[ipv4DnsNameServersFlag] = "1.1.1.1"
+ flagValues[ipv4GatewayFlag] = "10.1.2.3"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.IPv4DnsNameServers = utils.Ptr([]string{"1.1.1.1"})
+ model.IPv4Gateway = utils.Ptr("10.1.2.3")
+ }),
+ },
+ {
+ description: "use ipv4 gateway nil",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[noIpv4GatewayFlag] = "true"
+ delete(flagValues, ipv4GatewayFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.NoIPv4Gateway = true
+ model.IPv4Gateway = nil
+ }),
+ },
+ {
+ description: "use ipv6 dns servers and gateway",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[ipv6DnsNameServersFlag] = "2001:4860:4860::8888"
+ flagValues[ipv6GatewayFlag] = "2001:4860:4860::8888"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.IPv6DnsNameServers = utils.Ptr([]string{"2001:4860:4860::8888"})
+ model.IPv6Gateway = utils.Ptr("2001:4860:4860::8888")
+ }),
+ },
+ {
+ description: "use ipv6 gateway nil",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[noIpv6GatewayFlag] = "true"
+ delete(flagValues, ipv6GatewayFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.NoIPv6Gateway = true
+ model.IPv6Gateway = nil
+ }),
+ },
+ {
+ description: "labels missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelFlag)
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ isValid: true,
+ },
+ }
+
+ 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 iaas.ApiPartialUpdateNetworkRequest
+ }{
+ {
+ 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(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
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 5d145819b..9b8aae9a3 100644
--- a/internal/cmd/object-storage/bucket/create/create.go
+++ b/internal/cmd/object-storage/bucket/create/create.go
@@ -2,16 +2,17 @@ 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"
"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/stackitcloud/stackit-cli/internal/pkg/spinner"
"github.com/spf13/cobra"
@@ -28,7 +29,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("create %s", bucketNameArg),
Short: "Creates an Object Storage bucket",
@@ -41,22 +42,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
}
- 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)
+ if err != nil {
+ return fmt.Errorf("check if Object Storage is enabled: %w", err)
+ }
+ if !enabled {
+ return &errors.ServiceDisabledError{
+ Service: "object-storage",
}
}
@@ -69,16 +79,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 := spinner.New(params.Printer)
s.Start("Creating bucket")
- _, err = wait.CreateBucketWaitHandler(ctx, apiClient, model.ProjectId, model.BucketName).WaitWithContext(ctx)
+ _, err = wait.CreateBucketWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.BucketName).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for Object Storage bucket creation: %w", err)
}
s.Stop()
}
- return outputResult(p, model, resp)
+ return outputResult(params.Printer, model.OutputFormat, model.Async, model.BucketName, resp)
},
}
return cmd
@@ -97,47 +107,26 @@ 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.ApiCreateBucketRequest {
- req := apiClient.CreateBucket(ctx, model.ProjectId, model.BucketName)
+ req := apiClient.CreateBucket(ctx, model.ProjectId, model.Region, model.BucketName)
return req
}
-func outputResult(p *print.Printer, model *inputModel, resp *objectstorage.CreateBucketResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal Object Storage bucket: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, bucketName string, resp *objectstorage.CreateBucketResponse) error {
+ if resp == nil {
+ return fmt.Errorf("create bucket response is empty")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Created"
- if model.Async {
+ if async {
operationState = "Triggered creation of"
}
- p.Outputf("%s bucket %q\n", operationState, model.BucketName)
+ 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 1e53ed260..da4fcb9cf 100644
--- a/internal/cmd/object-storage/bucket/create/create_test.go
+++ b/internal/cmd/object-storage/bucket/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"
@@ -13,14 +16,16 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
-var testBucketName = "my-bucket"
+
+const (
+ testRegion = "eu01"
+ testBucketName = "my-bucket"
+)
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -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,
},
BucketName: testBucketName,
}
@@ -57,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiCreateBucketRequest)) objectstorage.ApiCreateBucketRequest {
- request := testClient.CreateBucket(testCtx, testProjectId, testBucketName)
+ request := testClient.CreateBucket(testCtx, testProjectId, testRegion, testBucketName)
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,
},
@@ -131,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)
})
}
}
@@ -210,3 +170,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ bucketName string
+ createBucketResponse *objectstorage.CreateBucketResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty create bucket response",
+ args: args{
+ createBucketResponse: &objectstorage.CreateBucketResponse{},
+ },
+ 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.bucketName, tt.args.createBucketResponse); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/object-storage/bucket/delete/delete.go b/internal/cmd/object-storage/bucket/delete/delete.go
index 359fcef99..c06e88718 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"
@@ -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,9 +67,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting bucket")
- _, err = wait.DeleteBucketWaitHandler(ctx, apiClient, model.ProjectId, model.BucketName).WaitWithContext(ctx)
+ _, err = wait.DeleteBucketWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.BucketName).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for Object Storage bucket deletion: %w", err)
}
@@ -80,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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.BucketName)
+ req := apiClient.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 1379397ea..829c374cf 100644
--- a/internal/cmd/object-storage/bucket/delete/delete_test.go
+++ b/internal/cmd/object-storage/bucket/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,14 +13,16 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
-var testBucketName = "my-bucket"
+
+const (
+ testRegion = "eu01"
+ testBucketName = "my-bucket"
+)
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -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{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
BucketName: testBucketName,
}
@@ -57,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiDeleteBucketRequest)) objectstorage.ApiDeleteBucketRequest {
- request := testClient.DeleteBucket(testCtx, testProjectId, testBucketName)
+ request := testClient.DeleteBucket(testCtx, testProjectId, testRegion, testBucketName)
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,
},
@@ -131,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/object-storage/bucket/describe/describe.go b/internal/cmd/object-storage/bucket/describe/describe.go
index 4196de7bc..a2a3ceedb 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,6 +13,7 @@ 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"
@@ -27,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("describe %s", bucketNameArg),
Short: "Shows details of an Object Storage bucket",
@@ -43,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
}
@@ -60,7 +61,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.Bucket)
},
}
return cmd
@@ -79,50 +80,29 @@ 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.BucketName)
+ req := apiClient.GetBucket(ctx, model.ProjectId, model.Region, model.BucketName)
return req
}
func outputResult(p *print.Printer, outputFormat string, bucket *objectstorage.Bucket) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal Object Storage bucket: %w", err)
- }
- p.Outputln(string(details))
+ if bucket == nil {
+ return fmt.Errorf("bucket is empty")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, bucket, func() error {
table := tables.NewTable()
- table.AddRow("Name", *bucket.Name)
+ table.AddRow("Name", utils.PtrString(bucket.Name))
table.AddSeparator()
- table.AddRow("Region", *bucket.Region)
+ table.AddRow("Region", utils.PtrString(bucket.Region))
table.AddSeparator()
- table.AddRow("URL (Path Style)", *bucket.UrlPathStyle)
+ table.AddRow("URL (Path Style)", utils.PtrString(bucket.UrlPathStyle))
table.AddSeparator()
- table.AddRow("URL (Virtual Hosted Style)", *bucket.UrlVirtualHostedStyle)
+ table.AddRow("URL (Virtual Hosted Style)", utils.PtrString(bucket.UrlVirtualHostedStyle))
table.AddSeparator()
err := table.Display(p)
if err != nil {
@@ -130,5 +110,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 8ba16b48f..106844ed2 100644
--- a/internal/cmd/object-storage/bucket/describe/describe_test.go
+++ b/internal/cmd/object-storage/bucket/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,14 +16,16 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
-var testBucketName = "my-bucket"
+
+const (
+ testRegion = "eu01"
+ testBucketName = "my-bucket"
+)
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -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,
},
BucketName: testBucketName,
}
@@ -57,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiGetBucketRequest)) objectstorage.ApiGetBucketRequest {
- request := testClient.GetBucket(testCtx, testProjectId, testBucketName)
+ request := testClient.GetBucket(testCtx, testProjectId, testRegion, testBucketName)
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,
},
@@ -131,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)
})
}
}
@@ -210,3 +170,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ bucket *objectstorage.Bucket
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty bucket",
+ args: args{
+ bucket: &objectstorage.Bucket{},
+ },
+ 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.bucket); (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 13367f035..ff01c60d6 100644
--- a/internal/cmd/object-storage/bucket/list/list.go
+++ b/internal/cmd/object-storage/bucket/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ 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/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
@@ -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",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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,47 +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)
+ req := apiClient.ListBuckets(ctx, model.ProjectId, model.Region)
return req
}
-func outputResult(p *print.Printer, outputFormat string, buckets []objectstorage.Bucket) error {
- switch outputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(buckets, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Object Storage bucket list: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, projectLabel string, buckets []objectstorage.Bucket) error {
+ if buckets == nil {
+ return fmt.Errorf("buckets is empty")
+ }
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(buckets, yaml.IndentSequence(true))
- 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
- default:
table := tables.NewTable()
table.SetHeader("NAME", "REGION", "URL (PATH STYLE)", "URL (VIRTUAL HOSTED STYLE)")
for i := range buckets {
bucket := buckets[i]
- table.AddRow(*bucket.Name, *bucket.Region, *bucket.UrlPathStyle, *bucket.UrlVirtualHostedStyle)
+ table.AddRow(
+ utils.PtrString(bucket.Name),
+ utils.PtrString(bucket.Region),
+ utils.PtrString(bucket.UrlPathStyle),
+ utils.PtrString(bucket.UrlVirtualHostedStyle),
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,5 +146,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 35d34ab64..47be7605e 100644
--- a/internal/cmd/object-storage/bucket/list/list_test.go
+++ b/internal/cmd/object-storage/bucket/list/list_test.go
@@ -4,29 +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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.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,
- limitFlag: "10",
+ globalflags.ProjectIdFlag: testProjectId,
+ limitFlag: "10",
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -39,6 +41,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
Limit: utils.Ptr(int64(10)),
}
@@ -49,7 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiListBucketsRequest)) objectstorage.ApiListBucketsRequest {
- request := testClient.ListBuckets(testCtx, testProjectId)
+ request := testClient.ListBuckets(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -59,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
@@ -77,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,
},
@@ -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)
})
}
}
@@ -186,3 +149,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ buckets []objectstorage.Bucket
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty create bucket response",
+ args: args{
+ buckets: []objectstorage.Bucket{},
+ },
+ 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.buckets); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/object-storage/credentials-group/create/create.go b/internal/cmd/object-storage/credentials-group/create/create.go
index d7399e501..9150b98fe 100644
--- a/internal/cmd/object-storage/credentials-group/create/create.go
+++ b/internal/cmd/object-storage/credentials-group/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"
@@ -28,7 +28,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",
@@ -41,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +65,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("create Object Storage credentials group: %w", err)
}
- return outputResult(p, model, resp)
+ return outputResult(params.Printer, model.OutputFormat, resp)
},
}
configureFlags(cmd)
@@ -81,7 +79,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,47 +90,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)
+ req := apiClient.CreateCredentialsGroup(ctx, model.ProjectId, model.Region)
req = req.CreateCredentialsGroupPayload(objectstorage.CreateCredentialsGroupPayload{
DisplayName: utils.Ptr(model.CredentialsGroupName),
})
return req
}
-func outputResult(p *print.Printer, model *inputModel, resp *objectstorage.CreateCredentialsGroupResponse) error {
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Object Storage credentials group: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Object Storage credentials group: %w", err)
- }
- p.Outputln(string(details))
+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")
+ }
+ return p.OutputResult(outputFormat, resp, func() error {
+ p.Outputf("Created credentials group %q. Credentials group ID: %s\n\n",
+ utils.PtrString(resp.CredentialsGroup.DisplayName),
+ utils.PtrString(resp.CredentialsGroup.CredentialsGroupId),
+ )
+ p.Outputf("URN: %s\n", utils.PtrString(resp.CredentialsGroup.Urn))
return nil
- default:
- p.Outputf("Created credentials group %q. Credentials group ID: %s\n\n", *resp.CredentialsGroup.DisplayName, *resp.CredentialsGroup.CredentialsGroupId)
- 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 88c212867..2823fc5da 100644
--- a/internal/cmd/object-storage/credentials-group/create/create_test.go
+++ b/internal/cmd/object-storage/credentials-group/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,19 +17,22 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
-var testCredentialsGroupName = "test-name"
+
+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,
+ globalflags.ProjectIdFlag: testProjectId,
+ credentialsGroupNameFlag: testCredentialsGroupName,
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -39,6 +45,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
CredentialsGroupName: testCredentialsGroupName,
}
@@ -59,7 +66,7 @@ func fixturePayload(mods ...func(payload *objectstorage.CreateCredentialsGroupPa
}
func fixtureRequest(mods ...func(request *objectstorage.ApiCreateCredentialsGroupRequest)) objectstorage.ApiCreateCredentialsGroupRequest {
- request := testClient.CreateCredentialsGroup(testCtx, testProjectId)
+ request := testClient.CreateCredentialsGroup(testCtx, testProjectId, testRegion)
request = request.CreateCredentialsGroupPayload(fixturePayload())
for _, mod := range mods {
mod(&request)
@@ -70,6 +77,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
@@ -88,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,
},
@@ -117,46 +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.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,3 +157,46 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ createCredentialsGroupResponse *objectstorage.CreateCredentialsGroupResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty create credentials group response",
+ args: args{
+ createCredentialsGroupResponse: &objectstorage.CreateCredentialsGroupResponse{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "set create credentials group response",
+ args: args{
+ createCredentialsGroupResponse: &objectstorage.CreateCredentialsGroupResponse{
+ CredentialsGroup: &objectstorage.CredentialsGroup{},
+ },
+ },
+ 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.createCredentialsGroupResponse); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 4f64600fb..79daa26b3 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"
@@ -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)
+ 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)
+ 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.CredentialsGroupId)
+ req := apiClient.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 4630501a0..9be471b32 100644
--- a/internal/cmd/object-storage/credentials-group/delete/delete_test.go
+++ b/internal/cmd/object-storage/credentials-group/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/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,6 +20,8 @@ var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
var testCredentialsGroupId = uuid.NewString()
+const testRegion = "eu01"
+
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testCredentialsGroupId,
@@ -34,7 +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,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -47,6 +48,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
CredentialsGroupId: testCredentialsGroupId,
}
@@ -57,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiDeleteCredentialsGroupRequest)) objectstorage.ApiDeleteCredentialsGroupRequest {
- request := testClient.DeleteCredentialsGroup(testCtx, testProjectId, testCredentialsGroupId)
+ request := testClient.DeleteCredentialsGroup(testCtx, testProjectId, testRegion, testCredentialsGroupId)
for _, mod := range mods {
mod(&request)
}
@@ -101,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,
},
@@ -109,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,
},
@@ -117,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,
},
@@ -137,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)
})
}
}
diff --git a/internal/cmd/object-storage/credentials-group/list/list.go b/internal/cmd/object-storage/credentials-group/list/list.go
index 073a88a9a..f422ad6d3 100644
--- a/internal/cmd/object-storage/credentials-group/list/list.go
+++ b/internal/cmd/object-storage/credentials-group/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -14,8 +15,7 @@ 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/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
@@ -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",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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,52 +100,36 @@ 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)
+ req := apiClient.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)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(credentialsGroups, yaml.IndentSequence(true))
- 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
- default:
table := tables.NewTable()
table.SetHeader("ID", "NAME", "URN")
for i := range credentialsGroups {
c := credentialsGroups[i]
- table.AddRow(*c.CredentialsGroupId, *c.DisplayName, *c.Urn)
+ table.AddRow(
+ utils.PtrString(c.CredentialsGroupId),
+ utils.PtrString(c.DisplayName),
+ utils.PtrString(c.Urn),
+ )
}
err := table.Display(p)
if err != nil {
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 43cd2e46a..cbbbb1a89 100644
--- a/internal/cmd/object-storage/credentials-group/list/list_test.go
+++ b/internal/cmd/object-storage/credentials-group/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,19 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
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,
+ limitFlag: "10",
+ globalflags.RegionFlag: "eu01",
}
for _, mod := range mods {
mod(flagValues)
@@ -38,6 +42,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
Limit: utils.Ptr(int64(10)),
}
@@ -48,7 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiListCredentialsGroupsRequest)) objectstorage.ApiListCredentialsGroupsRequest {
- request := testClient.ListCredentialsGroups(testCtx, testProjectId)
+ request := testClient.ListCredentialsGroups(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -58,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
@@ -76,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,
},
@@ -112,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)
})
}
}
@@ -183,3 +150,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentialsGroups []objectstorage.CredentialsGroup
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty credentials groups",
+ args: args{
+ credentialsGroups: []objectstorage.CredentialsGroup{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty credentials group",
+ args: args{
+ credentialsGroups: []objectstorage.CredentialsGroup{{}},
+ },
+ 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.credentialsGroups); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/object-storage/credentials/create/create.go b/internal/cmd/object-storage/credentials/create/create.go
index 2d9387cc3..c970dcb21 100644
--- a/internal/cmd/object-storage/credentials/create/create.go
+++ b/internal/cmd/object-storage/credentials/create/create.go
@@ -2,12 +2,10 @@ package create
import (
"context"
- "encoding/json"
"fmt"
"time"
- "github.com/goccy/go-yaml"
- objectStorageUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
@@ -17,6 +15,8 @@ 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"
+ 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"
)
@@ -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",
@@ -49,29 +49,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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)
+ 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)
+ 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, 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)
+ req := apiClient.CreateAccessKey(ctx, model.ProjectId, model.Region)
req = req.CredentialsGroup(model.CredentialsGroupId)
req = req.CreateAccessKeyPayload(objectstorage.CreateAccessKeyPayload{
Expires: model.ExpireDate,
@@ -137,35 +127,22 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *objectstora
return req
}
-func outputResult(p *print.Printer, model *inputModel, credentialsGroupLabel string, resp *objectstorage.CreateAccessKeyResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal Object Storage credentials: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, credentialsGroupLabel string, resp *objectstorage.CreateAccessKeyResponse) error {
+ if resp == nil {
+ return fmt.Errorf("create access key response is empty")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, resp, func() error {
expireDate := "Never"
- if resp.Expires != nil && *resp.Expires != "" {
- expireDate = *resp.Expires
+ if resp.Expires != nil && resp.Expires.IsSet() && *resp.Expires.Get() != "" {
+ expireDate = *resp.Expires.Get()
}
- 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("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("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 41e19eddd..46f0e2e18 100644
--- a/internal/cmd/object-storage/credentials/create/create_test.go
+++ b/internal/cmd/object-storage/credentials/create/create_test.go
@@ -5,8 +5,11 @@ 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"
@@ -15,21 +18,24 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
var testCredentialsGroupId = uuid.NewString()
-var testExpirationDate = "2024-01-01T00:00:00Z"
+
+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,
+ globalflags.ProjectIdFlag: testProjectId,
+ credentialsGroupIdFlag: testCredentialsGroupId,
+ expireDateFlag: testExpirationDate,
+ 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,
},
ExpireDate: utils.Ptr(testExpirationDate),
CredentialsGroupId: testCredentialsGroupId,
@@ -72,7 +79,7 @@ func fixturePayload(mods ...func(payload *objectstorage.CreateAccessKeyPayload))
}
func fixtureRequest(mods ...func(request *objectstorage.ApiCreateAccessKeyRequest)) objectstorage.ApiCreateAccessKeyRequest {
- request := testClient.CreateAccessKey(testCtx, testProjectId)
+ request := testClient.CreateAccessKey(testCtx, testProjectId, testRegion)
request = request.CreateAccessKeyPayload(fixturePayload())
request = request.CredentialsGroup(testCredentialsGroupId)
for _, mod := range mods {
@@ -84,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
@@ -102,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,
},
@@ -176,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)
})
}
}
@@ -247,3 +216,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentialsGroupLabel string
+ createAccessKeyResponse *objectstorage.CreateAccessKeyResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty create access key response",
+ args: args{
+ createAccessKeyResponse: &objectstorage.CreateAccessKeyResponse{},
+ },
+ 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.credentialsGroupLabel, tt.args.createAccessKeyResponse); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 a1baaf529..84a3b363b 100644
--- a/internal/cmd/object-storage/credentials/delete/delete.go
+++ b/internal/cmd/object-storage/credentials/delete/delete.go
@@ -4,6 +4,8 @@ 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"
@@ -27,7 +29,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 +42,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)
+ 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)
+ 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)
+ credentialsLabel, err := objectStorageUtils.GetCredentialsName(ctx, apiClient, 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 +78,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 +107,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.CredentialsId)
+ req := apiClient.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 146a52fee..699d36bcf 100644
--- a/internal/cmd/object-storage/credentials/delete/delete_test.go
+++ b/internal/cmd/object-storage/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,15 +13,17 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
var testCredentialsGroupId = uuid.NewString()
-var testCredentialsId = "keyID"
+
+const (
+ testCredentialsId = "keyID"
+ testRegion = "eu01"
+)
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -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,
- credentialsGroupIdFlag: testCredentialsGroupId,
+ globalflags.ProjectIdFlag: testProjectId,
+ credentialsGroupIdFlag: testCredentialsGroupId,
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -49,6 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
CredentialsGroupId: testCredentialsGroupId,
CredentialsId: testCredentialsId,
@@ -60,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiDeleteAccessKeyRequest)) objectstorage.ApiDeleteAccessKeyRequest {
- request := testClient.DeleteAccessKey(testCtx, testProjectId, testCredentialsId)
+ request := testClient.DeleteAccessKey(testCtx, testProjectId, testRegion, testCredentialsId)
request = request.CredentialsGroup(testCredentialsGroupId)
for _, mod := range mods {
mod(&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,
},
@@ -156,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/object-storage/credentials/list/list.go b/internal/cmd/object-storage/credentials/list/list.go
index 3814a0464..f1ef8c155 100644
--- a/internal/cmd/object-storage/credentials/list/list.go
+++ b/internal/cmd/object-storage/credentials/list/list.go
@@ -2,12 +2,11 @@ package list
import (
"context"
- "encoding/json"
"fmt"
- "github.com/goccy/go-yaml"
- objectStorageUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/object-storage/utils"
+ "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"
@@ -15,9 +14,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/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/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
@@ -32,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",
@@ -51,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +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)
- 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, 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)
@@ -99,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{}
@@ -119,58 +114,35 @@ 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)
+ req := apiClient.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)
+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
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Object Storage credentials list: %w", err)
- }
- 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 := "Never"
- if c.Expires != nil {
- expiresAt = *c.Expires
- }
- table.AddRow(*c.KeyId, *c.DisplayName, expiresAt)
+ expiresAt := utils.PtrStringDefault(c.Expires, "Never")
+ table.AddRow(utils.PtrString(c.KeyId), utils.PtrString(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 27845efa5..ba6967c84 100644
--- a/internal/cmd/object-storage/credentials/list/list_test.go
+++ b/internal/cmd/object-storage/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,20 +17,20 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
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",
+ globalflags.ProjectIdFlag: testProjectId,
+ credentialsGroupIdFlag: testCredentialsGroupId,
+ limitFlag: "10",
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -40,6 +43,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
CredentialsGroupId: testCredentialsGroupId,
Limit: utils.Ptr(int64(10)),
@@ -51,7 +55,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiListAccessKeysRequest)) objectstorage.ApiListAccessKeysRequest {
- request := testClient.ListAccessKeys(testCtx, testProjectId)
+ request := testClient.ListAccessKeys(testCtx, testProjectId, testRegion)
request = request.CredentialsGroup(testCredentialsGroupId)
for _, mod := range mods {
mod(&request)
@@ -62,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
@@ -80,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,
},
@@ -137,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)
})
}
}
@@ -208,3 +174,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentialsGroupLabel string
+ credentials []objectstorage.AccessKey
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty credentials",
+ args: args{
+ credentials: []objectstorage.AccessKey{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty access key",
+ args: args{
+ credentials: []objectstorage.AccessKey{{}},
+ },
+ 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.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 d38546ac4..e7b0f501b 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"
@@ -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: "disable",
Short: "Disables Object Storage for a project",
@@ -33,29 +35,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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)
+ req := apiClient.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 969b92b71..8820b8cc3 100644
--- a/internal/cmd/object-storage/disable/disable_test.go
+++ b/internal/cmd/object-storage/disable/disable_test.go
@@ -5,26 +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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
+const testRegion = "eu01"
+
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)
@@ -37,6 +37,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
}
for _, mod := range mods {
@@ -46,7 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiDisableServiceRequest)) objectstorage.ApiDisableServiceRequest {
- request := testClient.DisableService(testCtx, testProjectId)
+ request := testClient.DisableService(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -56,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
@@ -74,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,
},
@@ -96,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/object-storage/enable/enable.go b/internal/cmd/object-storage/enable/enable.go
index 42c678784..bd5304493 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"
@@ -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: "enable",
Short: "Enables Object Storage for a project",
@@ -33,29 +35,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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)
+ req := apiClient.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 3fbca13d1..db8c35d3f 100644
--- a/internal/cmd/object-storage/enable/enable_test.go
+++ b/internal/cmd/object-storage/enable/enable_test.go
@@ -5,26 +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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &objectstorage.APIClient{}
var testProjectId = uuid.NewString()
+const testRegion = "eu01"
+
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)
@@ -37,6 +37,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
}
for _, mod := range mods {
@@ -46,7 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *objectstorage.ApiEnableServiceRequest)) objectstorage.ApiEnableServiceRequest {
- request := testClient.EnableService(testCtx, testProjectId)
+ request := testClient.EnableService(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -56,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
@@ -74,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,
},
@@ -96,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/object-storage/object_storage.go b/internal/cmd/object-storage/object_storage.go
index 0ba397592..88358e0d8 100644
--- a/internal/cmd/object-storage/object_storage.go
+++ b/internal/cmd/object-storage/object_storage.go
@@ -7,13 +7,13 @@ import (
"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 +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(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))
}
diff --git a/internal/cmd/observability/credentials/create/create.go b/internal/cmd/observability/credentials/create/create.go
new file mode 100644
index 000000000..12ff53649
--- /dev/null
+++ b/internal/cmd/observability/credentials/create/create.go
@@ -0,0 +1,130 @@
+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/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/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 (
+ instanceIdFlag = "instance-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ InstanceId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates credentials for an Observability instance.",
+ Long: fmt.Sprintf("%s\n%s",
+ "Creates credentials (username and password) for an Observability instance.",
+ "The credentials will be generated and included in the response. You won't be able to retrieve the password later."),
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create credentials for Observability instance with ID "xxx"`,
+ "$ stackit observability credentials create --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 := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ 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 credentials for instance %q?", instanceLabel)
+ 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 credentials for Observability instance: %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, "Instance ID")
+
+ 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, &cliErr.ProjectIdError{}
+ }
+
+ return &inputModel{
+ GlobalFlagModel: globalFlags,
+ InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag),
+ }, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiCreateCredentialsRequest {
+ req := apiClient.CreateCredentials(ctx, model.InstanceId, model.ProjectId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat, instanceLabel string, resp *observability.CreateCredentialsResponse) error {
+ if resp == nil {
+ return fmt.Errorf("response is nil")
+ }
+
+ return p.OutputResult(outputFormat, resp, func() error {
+ p.Outputf("Created credentials for instance %q.\n\n", instanceLabel)
+
+ if resp.Credentials != nil {
+ // The username field cannot be set by the user, so we only display it if it's not returned empty
+ username := *resp.Credentials.Username
+ if username != "" {
+ p.Outputf("Username: %s\n", username)
+ }
+
+ p.Outputf("Password: %s\n", utils.PtrString(resp.Credentials.Password))
+ }
+ return nil
+ })
+}
diff --git a/internal/cmd/argus/credentials/create/create_test.go b/internal/cmd/observability/credentials/create/create_test.go
similarity index 67%
rename from internal/cmd/argus/credentials/create/create_test.go
rename to internal/cmd/observability/credentials/create/create_test.go
index 6bc528545..c00d81989 100644
--- a/internal/cmd/argus/credentials/create/create_test.go
+++ b/internal/cmd/observability/credentials/create/create_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-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
@@ -17,7 +21,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
@@ -46,7 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiCreateCredentialsRequest)) argus.ApiCreateCredentialsRequest {
+func fixtureRequest(mods ...func(request *observability.ApiCreateCredentialsRequest)) observability.ApiCreateCredentialsRequest {
request := testClient.CreateCredentials(testCtx, testInstanceId, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -57,6 +61,7 @@ func fixtureRequest(mods ...func(request *argus.ApiCreateCredentialsRequest)) ar
func TestParseInput(t *testing.T) {
tests := []struct {
description string
+ argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
@@ -118,45 +123,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)
})
}
}
@@ -165,7 +132,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiCreateCredentialsRequest
+ expectedRequest observability.ApiCreateCredentialsRequest
}{
{
description: "base",
@@ -188,3 +155,47 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ resp *observability.CreateCredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty response",
+ args: args{
+ resp: &observability.CreateCredentialsResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set response with credentials",
+ args: args{
+ resp: &observability.CreateCredentialsResponse{
+ Credentials: observability.NewCredentials("dummy-pw", "dummy-user"),
+ },
+ },
+ 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.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/observability/credentials/credentials.go b/internal/cmd/observability/credentials/credentials.go
new file mode 100644
index 000000000..2c40cc3d2
--- /dev/null
+++ b/internal/cmd/observability/credentials/credentials.go
@@ -0,0 +1,30 @@
+package credentials
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/credentials/create"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "credentials",
+ Short: "Provides functionality for Observability credentials",
+ Long: "Provides functionality for Observability credentials.",
+ 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))
+}
diff --git a/internal/cmd/argus/credentials/delete/delete.go b/internal/cmd/observability/credentials/delete/delete.go
similarity index 57%
rename from internal/cmd/argus/credentials/delete/delete.go
rename to internal/cmd/observability/credentials/delete/delete.go
index b81ab3ddb..a28888dc9 100644
--- a/internal/cmd/argus/credentials/delete/delete.go
+++ b/internal/cmd/observability/credentials/delete/delete.go
@@ -4,17 +4,19 @@ 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/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client"
+ observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils"
"github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -29,52 +31,50 @@ 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 Argus instance",
- Long: "Deletes credentials of an Argus instance.",
+ Short: "Deletes credentials of an Observability instance",
+ Long: "Deletes credentials of an Observability instance.",
Args: args.SingleArg(usernameArg, nil),
Example: examples.Build(
examples.NewExample(
- `Delete credentials of username "xxx" for Argus instance with ID "yyy"`,
- "$ stackit argus credentials delete xxx --instance-id yyy"),
+ `Delete credentials of username "xxx" for Observability instance with ID "yyy"`,
+ "$ stackit observability credentials delete xxx --instance-id yyy"),
),
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 := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ 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
req := buildRequest(ctx, model, apiClient)
_, err = req.Execute()
if err != nil {
- return fmt.Errorf("delete Argus credentials: %w", err)
+ 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
},
}
@@ -104,7 +104,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
}, nil
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiDeleteCredentialsRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiDeleteCredentialsRequest {
req := apiClient.DeleteCredentials(ctx, model.InstanceId, model.ProjectId, model.Username)
return req
}
diff --git a/internal/cmd/argus/credentials/delete/delete_test.go b/internal/cmd/observability/credentials/delete/delete_test.go
similarity index 94%
rename from internal/cmd/argus/credentials/delete/delete_test.go
rename to internal/cmd/observability/credentials/delete/delete_test.go
index 28034e8d9..21965e75c 100644
--- a/internal/cmd/argus/credentials/delete/delete_test.go
+++ b/internal/cmd/observability/credentials/delete/delete_test.go
@@ -9,7 +9,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/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -17,7 +17,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
@@ -59,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiDeleteCredentialsRequest)) argus.ApiDeleteCredentialsRequest {
+func fixtureRequest(mods ...func(request *observability.ApiDeleteCredentialsRequest)) observability.ApiDeleteCredentialsRequest {
request := testClient.DeleteCredentials(testCtx, testInstanceId, testProjectId, testUsername)
for _, mod := range mods {
mod(&request)
@@ -213,7 +213,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiDeleteCredentialsRequest
+ expectedRequest observability.ApiDeleteCredentialsRequest
}{
{
description: "base",
diff --git a/internal/cmd/argus/credentials/list/list.go b/internal/cmd/observability/credentials/list/list.go
similarity index 56%
rename from internal/cmd/argus/credentials/list/list.go
rename to internal/cmd/observability/credentials/list/list.go
index 795b4278e..f26af68de 100644
--- a/internal/cmd/argus/credentials/list/list.go
+++ b/internal/cmd/observability/credentials/list/list.go
@@ -2,22 +2,23 @@ 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"
"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/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/tables"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -31,32 +32,32 @@ 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 Argus instance",
- Long: "Lists the usernames of all credentials for an Argus instance.",
+ Short: "Lists the usernames of all credentials for an Observability instance",
+ Long: "Lists the usernames of all credentials for an Observability instance.",
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
- `List the usernames of all credentials for an Argus instance with ID "xxx"`,
- "$ stackit argus credentials list --instance-id xxx"),
+ `List the usernames of all credentials for an Observability instance with ID "xxx"`,
+ "$ stackit observability credentials list --instance-id xxx"),
examples.NewExample(
- `List the usernames of all credentials for an Argus instance in JSON format`,
- "$ stackit argus credentials list --instance-id xxx --output-format json"),
+ `List the usernames of all credentials for an Observability instance in JSON format`,
+ "$ stackit observability credentials list --instance-id xxx --output-format json"),
examples.NewExample(
- `List the usernames of up to 10 credentials for an Argus instance`,
- "$ stackit argus credentials list --instance-id xxx --limit 10"),
+ `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, 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,16 +66,16 @@ func NewCmd(p *print.Printer) *cobra.Command {
req := buildRequest(ctx, model, apiClient)
resp, err := req.Execute()
if err != nil {
- return fmt.Errorf("list Argus credentials: %w", err)
+ return fmt.Errorf("list Observability credentials: %w", err)
}
credentials := *resp.Credentials
if len(credentials) == 0 {
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ 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
}
@@ -82,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)
@@ -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{}
@@ -118,35 +119,18 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
}, nil
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiListCredentialsRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiListCredentialsRequest {
req := apiClient.ListCredentials(ctx, model.InstanceId, model.ProjectId)
return req
}
-func outputResult(p *print.Printer, outputFormat string, credentials []argus.ServiceKeysList) error {
- switch outputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(credentials, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Argus credentials list: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Argus credentials list: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+func outputResult(p *print.Printer, outputFormat string, credentials []observability.ServiceKeysList) error {
+ return p.OutputResult(outputFormat, credentials, func() error {
table := tables.NewTable()
table.SetHeader("USERNAME")
for i := range credentials {
c := credentials[i]
- table.AddRow(*c.Name)
+ table.AddRow(utils.PtrString(c.Name))
}
err := table.Display(p)
if err != nil {
@@ -154,5 +138,5 @@ func outputResult(p *print.Printer, outputFormat string, credentials []argus.Ser
}
return nil
- }
+ })
}
diff --git a/internal/cmd/argus/credentials/list/list_test.go b/internal/cmd/observability/credentials/list/list_test.go
similarity index 71%
rename from internal/cmd/argus/credentials/list/list_test.go
rename to internal/cmd/observability/credentials/list/list_test.go
index 14509ec68..f2ed00e46 100644
--- a/internal/cmd/argus/credentials/list/list_test.go
+++ b/internal/cmd/observability/credentials/list/list_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/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -18,7 +22,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
@@ -49,7 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiListCredentialsRequest)) argus.ApiListCredentialsRequest {
+func fixtureRequest(mods ...func(request *observability.ApiListCredentialsRequest)) observability.ApiListCredentialsRequest {
request := testClient.ListCredentials(testCtx, testInstanceId, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -60,6 +64,7 @@ func fixtureRequest(mods ...func(request *argus.ApiListCredentialsRequest)) argu
func TestParseInput(t *testing.T) {
tests := []struct {
description string
+ argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
@@ -135,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)
})
}
}
@@ -182,7 +149,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiListCredentialsRequest
+ expectedRequest observability.ApiListCredentialsRequest
}{
{
description: "base",
@@ -205,3 +172,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials []observability.ServiceKeysList
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty credentials slice",
+ args: args{
+ credentials: []observability.ServiceKeysList{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty credential in credentials slice",
+ args: args{
+ credentials: []observability.ServiceKeysList{{}},
+ },
+ 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.credentials); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/argus/grafana/describe/describe.go b/internal/cmd/observability/grafana/describe/describe.go
similarity index 57%
rename from internal/cmd/argus/grafana/describe/describe.go
rename to internal/cmd/observability/grafana/describe/describe.go
index 4c8f930b7..674104a9b 100644
--- a/internal/cmd/argus/grafana/describe/describe.go
+++ b/internal/cmd/observability/grafana/describe/describe.go
@@ -2,22 +2,22 @@ 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"
"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/argus/client"
+ "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/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -31,36 +31,36 @@ 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 Argus instance",
+ Short: "Shows details of the Grafana configuration of an Observability instance",
Long: fmt.Sprintf("%s\n%s\n%s",
- "Shows details of the Grafana configuration of an Argus 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.`,
),
Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
Example: examples.Build(
examples.NewExample(
- `Get details of the Grafana configuration of an Argus instance with ID "xxx"`,
- "$ stackit argus credentials describe xxx"),
+ `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 Argus instance with ID "xxx" and show the initial admin password`,
- "$ stackit argus credentials describe xxx --show-password"),
+ `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 Argus instance with ID "xxx" in JSON format`,
- "$ stackit argus credentials describe xxx --output-format json"),
+ `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 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("get instance: %w", err)
}
- return outputResult(p, model, grafanaConfigsResp, instanceResp)
+ return outputResult(params.Printer, model.OutputFormat, model.ShowPassword, grafanaConfigsResp, instanceResp)
},
}
configureFlags(cmd)
@@ -102,60 +102,41 @@ 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
}
-func buildGetGrafanaConfigRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiGetGrafanaConfigsRequest {
+func buildGetGrafanaConfigRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiGetGrafanaConfigsRequest {
req := apiClient.GetGrafanaConfigs(ctx, model.InstanceId, model.ProjectId)
return req
}
-func buildGetInstanceRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiGetInstanceRequest {
+func buildGetInstanceRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiGetInstanceRequest {
req := apiClient.GetInstance(ctx, model.InstanceId, model.ProjectId)
return req
}
-func outputResult(p *print.Printer, inputModel *inputModel, grafanaConfigs *argus.GrafanaConfigs, instance *argus.GetInstanceResponse) error {
- switch inputModel.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))
- if err != nil {
- return fmt.Errorf("marshal Grafana configs: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, showPassword bool, grafanaConfigs *observability.GrafanaConfigs, instance *observability.GetInstanceResponse) error {
+ if instance == nil || instance.Instance == nil {
+ return fmt.Errorf("instance or instance content is nil")
+ } else if grafanaConfigs == nil {
+ return fmt.Errorf("grafanaConfigs is nil")
+ }
- return nil
- default:
- initialAdminPassword := *instance.Instance.GrafanaAdminPassword
- if !inputModel.ShowPassword {
+ return p.OutputResult(outputFormat, grafanaConfigs, func() error {
+ initialAdminPassword := utils.PtrString(instance.Instance.GrafanaAdminPassword)
+ if !showPassword {
initialAdminPassword = ""
}
table := tables.NewTable()
- table.AddRow("GRAFANA DASHBOARD", *instance.Instance.GrafanaUrl)
+ table.AddRow("GRAFANA DASHBOARD", utils.PtrString(instance.Instance.GrafanaUrl))
table.AddSeparator()
- table.AddRow("PUBLIC READ ACCESS", *grafanaConfigs.PublicReadAccess)
+ table.AddRow("PUBLIC READ ACCESS", utils.PtrString(grafanaConfigs.PublicReadAccess))
table.AddSeparator()
- table.AddRow("SINGLE SIGN-ON", *grafanaConfigs.UseStackitSso)
+ table.AddRow("SINGLE SIGN-ON", utils.PtrString(grafanaConfigs.UseStackitSso))
table.AddSeparator()
- table.AddRow("INITIAL ADMIN USER (DEFAULT)", *instance.Instance.GrafanaAdminUser)
+ table.AddRow("INITIAL ADMIN USER (DEFAULT)", utils.PtrString(instance.Instance.GrafanaAdminUser))
table.AddSeparator()
table.AddRow("INITIAL ADMIN PASSWORD (DEFAULT)", initialAdminPassword)
err := table.Display(p)
@@ -164,5 +145,5 @@ func outputResult(p *print.Printer, inputModel *inputModel, grafanaConfigs *argu
}
return nil
- }
+ })
}
diff --git a/internal/cmd/argus/grafana/describe/describe_test.go b/internal/cmd/observability/grafana/describe/describe_test.go
similarity index 76%
rename from internal/cmd/argus/grafana/describe/describe_test.go
rename to internal/cmd/observability/grafana/describe/describe_test.go
index 2bc97589b..c1aeb443d 100644
--- a/internal/cmd/argus/grafana/describe/describe_test.go
+++ b/internal/cmd/observability/grafana/describe/describe_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/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -18,7 +20,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
@@ -56,7 +58,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureGetGrafanaConfigsRequest(mods ...func(request *argus.ApiGetGrafanaConfigsRequest)) argus.ApiGetGrafanaConfigsRequest {
+func fixtureGetGrafanaConfigsRequest(mods ...func(request *observability.ApiGetGrafanaConfigsRequest)) observability.ApiGetGrafanaConfigsRequest {
request := testClient.GetGrafanaConfigs(testCtx, testInstanceId, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -64,7 +66,7 @@ func fixtureGetGrafanaConfigsRequest(mods ...func(request *argus.ApiGetGrafanaCo
return request
}
-func fixtureGetInstanceRequest(mods ...func(request *argus.ApiGetInstanceRequest)) argus.ApiGetInstanceRequest {
+func fixtureGetInstanceRequest(mods ...func(request *observability.ApiGetInstanceRequest)) observability.ApiGetInstanceRequest {
request := testClient.GetInstance(testCtx, testInstanceId, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -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)
@@ -225,7 +227,7 @@ func TestBuildGetGrafanaConfigsRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiGetGrafanaConfigsRequest
+ expectedRequest observability.ApiGetGrafanaConfigsRequest
}{
{
description: "base",
@@ -253,7 +255,7 @@ func TestBuildGetInstanceRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiGetInstanceRequest
+ expectedRequest observability.ApiGetInstanceRequest
}{
{
description: "base",
@@ -276,3 +278,58 @@ func TestBuildGetInstanceRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ showPassword bool
+ grafanaConfig *observability.GrafanaConfigs
+ instance *observability.GetInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set grafana configs but no instance",
+ args: args{
+ grafanaConfig: &observability.GrafanaConfigs{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "set instance but no grafana config",
+ args: args{
+ instance: &observability.GetInstanceResponse{
+ Instance: &observability.InstanceSensitiveData{},
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "set instance and grafana configs",
+ args: args{
+ grafanaConfig: &observability.GrafanaConfigs{},
+ instance: &observability.GetInstanceResponse{
+ Instance: &observability.InstanceSensitiveData{},
+ },
+ },
+ 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.showPassword, tt.args.grafanaConfig, tt.args.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/observability/grafana/grafana.go b/internal/cmd/observability/grafana/grafana.go
new file mode 100644
index 000000000..7ba2a996f
--- /dev/null
+++ b/internal/cmd/observability/grafana/grafana.go
@@ -0,0 +1,30 @@
+package grafana
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/grafana/describe"
+ 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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "grafana",
+ Short: "Provides functionality for the Grafana configuration of Observability instances",
+ Long: "Provides functionality for the Grafana configuration of Observability instances.",
+ 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(publicreadaccess.NewCmd(params))
+ cmd.AddCommand(singlesignon.NewCmd(params))
+}
diff --git a/internal/cmd/argus/grafana/public-read-access/disable/disable.go b/internal/cmd/observability/grafana/public-read-access/disable/disable.go
similarity index 58%
rename from internal/cmd/argus/grafana/public-read-access/disable/disable.go
rename to internal/cmd/observability/grafana/public-read-access/disable/disable.go
index fe5383380..70a3ff85b 100644
--- a/internal/cmd/argus/grafana/public-read-access/disable/disable.go
+++ b/internal/cmd/observability/grafana/public-read-access/disable/disable.go
@@ -4,17 +4,19 @@ 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/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
)
const (
@@ -26,44 +28,42 @@ 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 Argus instances",
+ Short: "Disables public read access for Grafana on Observability instances",
Long: fmt.Sprintf("%s\n%s",
- "Disables public read access for Grafana on Argus instances.",
+ "Disables public read access for Grafana on Observability instances.",
"When disabled, a login is required to access the Grafana dashboards of the instance. Otherwise, anyone can access the dashboards.",
),
Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
Example: examples.Build(
examples.NewExample(
- `Disable public read access for Grafana on an Argus instance with ID "xxx"`,
- "$ stackit argus grafana public-read-access disable xxx"),
+ `Disable public read access for Grafana on an Observability instance with ID "xxx"`,
+ "$ stackit observability grafana public-read-access disable 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
}
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
if err != nil || instanceLabel == "" {
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 +76,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,21 +96,13 @@ 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 argusUtils.ArgusClient) (argus.ApiUpdateGrafanaConfigsRequest, error) {
+func buildRequest(ctx context.Context, model *inputModel, apiClient observabilityUtils.ObservabilityClient) (observability.ApiUpdateGrafanaConfigsRequest, error) {
req := apiClient.UpdateGrafanaConfigs(ctx, model.InstanceId, model.ProjectId)
- payload, err := argusUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, nil, utils.Ptr(false))
+ payload, err := observabilityUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, nil, utils.Ptr(false))
if err != nil {
return req, fmt.Errorf("build request payload: %w", err)
}
diff --git a/internal/cmd/argus/grafana/public-read-access/disable/disable_test.go b/internal/cmd/observability/grafana/public-read-access/disable/disable_test.go
similarity index 67%
rename from internal/cmd/argus/grafana/public-read-access/disable/disable_test.go
rename to internal/cmd/observability/grafana/public-read-access/disable/disable_test.go
index b52ba7223..58fdfd098 100644
--- a/internal/cmd/argus/grafana/public-read-access/disable/disable_test.go
+++ b/internal/cmd/observability/grafana/public-read-access/disable/disable_test.go
@@ -6,14 +6,14 @@ import (
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/argus"
+ observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -21,24 +21,24 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
-type argusClientMocked struct {
+type observabilityClientMocked struct {
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
}
-func (c *argusClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) {
+func (c *observabilityClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*observability.GetInstanceResponse, error) {
return testClient.GetInstanceExecute(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) argus.ApiUpdateGrafanaConfigsRequest {
+func (c *observabilityClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) observability.ApiUpdateGrafanaConfigsRequest {
return testClient.UpdateGrafanaConfigs(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*argus.GrafanaConfigs, error) {
+func (c *observabilityClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*observability.GrafanaConfigs, error) {
if c.getGrafanaConfigsFails {
return nil, fmt.Errorf("get payload failed")
}
@@ -79,9 +79,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.GrafanaConfigs {
- gc := argus.GrafanaConfigs{
- GenericOauth: &argus.GrafanaOauth{
+func fixtureGrafanaConfigs(mods ...func(gc *observability.GrafanaConfigs)) *observability.GrafanaConfigs {
+ gc := observability.GrafanaConfigs{
+ GenericOauth: &observability.GrafanaOauth{
ApiUrl: utils.Ptr("apiUrl"),
AuthUrl: utils.Ptr("authUrl"),
Enabled: utils.Ptr(true),
@@ -103,9 +103,9 @@ func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.Grafan
return &gc
}
-func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *argus.UpdateGrafanaConfigsPayload {
- payload := &argus.UpdateGrafanaConfigsPayload{
- GenericOauth: argusUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
+func fixturePayload(mods ...func(payload *observability.UpdateGrafanaConfigsPayload)) *observability.UpdateGrafanaConfigsPayload {
+ payload := &observability.UpdateGrafanaConfigsPayload{
+ GenericOauth: observabilityUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
PublicReadAccess: utils.Ptr(false),
UseStackitSso: fixtureGrafanaConfigs().UseStackitSso,
}
@@ -115,7 +115,7 @@ func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *a
return payload
}
-func fixtureRequest(mods ...func(request *argus.ApiUpdateGrafanaConfigsRequest)) argus.ApiUpdateGrafanaConfigsRequest {
+func fixtureRequest(mods ...func(request *observability.ApiUpdateGrafanaConfigsRequest)) observability.ApiUpdateGrafanaConfigsRequest {
request := testClient.UpdateGrafanaConfigs(testCtx, testInstanceId, testProjectId)
request = request.UpdateGrafanaConfigsPayload(*fixturePayload())
for _, mod := range mods {
@@ -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)
})
}
}
@@ -242,9 +195,9 @@ func TestBuildRequest(t *testing.T) {
description string
model *inputModel
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
isValid bool
- expectedRequest argus.ApiUpdateGrafanaConfigsRequest
+ expectedRequest observability.ApiUpdateGrafanaConfigsRequest
}{
{
description: "base",
@@ -256,12 +209,12 @@ func TestBuildRequest(t *testing.T) {
{
description: "nil generic oauth",
model: fixtureInputModel(),
- getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *argus.GrafanaConfigs) {
+ getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *observability.GrafanaConfigs) {
gc.GenericOauth = nil
}),
isValid: true,
- expectedRequest: fixtureRequest(func(request *argus.ApiUpdateGrafanaConfigsRequest) {
- *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *argus.UpdateGrafanaConfigsPayload) {
+ expectedRequest: fixtureRequest(func(request *observability.ApiUpdateGrafanaConfigsRequest) {
+ *request = (*request).UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) {
payload.GenericOauth = nil
}))
}),
@@ -282,7 +235,7 @@ func TestBuildRequest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- client := &argusClientMocked{
+ client := &observabilityClientMocked{
getGrafanaConfigsFails: tt.getGrafanaConfigsFails,
getGrafanaConfigsResp: tt.getGrafanaConfigsResp,
}
diff --git a/internal/cmd/argus/grafana/public-read-access/enable/enable.go b/internal/cmd/observability/grafana/public-read-access/enable/enable.go
similarity index 58%
rename from internal/cmd/argus/grafana/public-read-access/enable/enable.go
rename to internal/cmd/observability/grafana/public-read-access/enable/enable.go
index 07c105fb4..04f9eabf2 100644
--- a/internal/cmd/argus/grafana/public-read-access/enable/enable.go
+++ b/internal/cmd/observability/grafana/public-read-access/enable/enable.go
@@ -4,17 +4,19 @@ 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/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -26,44 +28,42 @@ 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 Argus instances",
+ Short: "Enables public read access for Grafana on Observability instances",
Long: fmt.Sprintf("%s\n%s",
- "Enables public read access for Grafana on Argus instances.",
+ "Enables public read access for Grafana on Observability instances.",
"When enabled, anyone can access the Grafana dashboards of the instance without logging in. Otherwise, a login is required.",
),
Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
Example: examples.Build(
examples.NewExample(
- `Enable public read access for Grafana on an Argus instance with ID "xxx"`,
- "$ stackit argus grafana public-read-access enable xxx"),
+ `Enable public read access for Grafana on an Observability instance with ID "xxx"`,
+ "$ stackit observability grafana public-read-access enable 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
}
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
if err != nil || instanceLabel == "" {
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 +76,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,21 +96,13 @@ 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 argusUtils.ArgusClient) (argus.ApiUpdateGrafanaConfigsRequest, error) {
+func buildRequest(ctx context.Context, model *inputModel, apiClient observabilityUtils.ObservabilityClient) (observability.ApiUpdateGrafanaConfigsRequest, error) {
req := apiClient.UpdateGrafanaConfigs(ctx, model.InstanceId, model.ProjectId)
- payload, err := argusUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, nil, utils.Ptr(true))
+ payload, err := observabilityUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, nil, utils.Ptr(true))
if err != nil {
return req, fmt.Errorf("build request payload: %w", err)
}
diff --git a/internal/cmd/argus/grafana/public-read-access/enable/enable_test.go b/internal/cmd/observability/grafana/public-read-access/enable/enable_test.go
similarity index 67%
rename from internal/cmd/argus/grafana/public-read-access/enable/enable_test.go
rename to internal/cmd/observability/grafana/public-read-access/enable/enable_test.go
index 04d80e287..1f3d0fb89 100644
--- a/internal/cmd/argus/grafana/public-read-access/enable/enable_test.go
+++ b/internal/cmd/observability/grafana/public-read-access/enable/enable_test.go
@@ -6,14 +6,14 @@ import (
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/argus"
+ observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -21,24 +21,24 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
-type argusClientMocked struct {
+type observabilityClientMocked struct {
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
}
-func (c *argusClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) {
+func (c *observabilityClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*observability.GetInstanceResponse, error) {
return testClient.GetInstanceExecute(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) argus.ApiUpdateGrafanaConfigsRequest {
+func (c *observabilityClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) observability.ApiUpdateGrafanaConfigsRequest {
return testClient.UpdateGrafanaConfigs(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*argus.GrafanaConfigs, error) {
+func (c *observabilityClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*observability.GrafanaConfigs, error) {
if c.getGrafanaConfigsFails {
return nil, fmt.Errorf("get payload failed")
}
@@ -79,9 +79,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.GrafanaConfigs {
- gc := argus.GrafanaConfigs{
- GenericOauth: &argus.GrafanaOauth{
+func fixtureGrafanaConfigs(mods ...func(gc *observability.GrafanaConfigs)) *observability.GrafanaConfigs {
+ gc := observability.GrafanaConfigs{
+ GenericOauth: &observability.GrafanaOauth{
ApiUrl: utils.Ptr("apiUrl"),
AuthUrl: utils.Ptr("authUrl"),
Enabled: utils.Ptr(true),
@@ -103,9 +103,9 @@ func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.Grafan
return &gc
}
-func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *argus.UpdateGrafanaConfigsPayload {
- payload := &argus.UpdateGrafanaConfigsPayload{
- GenericOauth: argusUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
+func fixturePayload(mods ...func(payload *observability.UpdateGrafanaConfigsPayload)) *observability.UpdateGrafanaConfigsPayload {
+ payload := &observability.UpdateGrafanaConfigsPayload{
+ GenericOauth: observabilityUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
PublicReadAccess: utils.Ptr(true),
UseStackitSso: fixtureGrafanaConfigs().UseStackitSso,
}
@@ -115,7 +115,7 @@ func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *a
return payload
}
-func fixtureRequest(mods ...func(request *argus.ApiUpdateGrafanaConfigsRequest)) argus.ApiUpdateGrafanaConfigsRequest {
+func fixtureRequest(mods ...func(request *observability.ApiUpdateGrafanaConfigsRequest)) observability.ApiUpdateGrafanaConfigsRequest {
request := testClient.UpdateGrafanaConfigs(testCtx, testInstanceId, testProjectId)
request = request.UpdateGrafanaConfigsPayload(*fixturePayload())
for _, mod := range mods {
@@ -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)
})
}
}
@@ -242,9 +195,9 @@ func TestBuildRequest(t *testing.T) {
description string
model *inputModel
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
isValid bool
- expectedRequest argus.ApiUpdateGrafanaConfigsRequest
+ expectedRequest observability.ApiUpdateGrafanaConfigsRequest
}{
{
description: "base",
@@ -256,12 +209,12 @@ func TestBuildRequest(t *testing.T) {
{
description: "nil generic oauth",
model: fixtureInputModel(),
- getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *argus.GrafanaConfigs) {
+ getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *observability.GrafanaConfigs) {
gc.GenericOauth = nil
}),
isValid: true,
- expectedRequest: fixtureRequest(func(request *argus.ApiUpdateGrafanaConfigsRequest) {
- *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *argus.UpdateGrafanaConfigsPayload) {
+ expectedRequest: fixtureRequest(func(request *observability.ApiUpdateGrafanaConfigsRequest) {
+ *request = (*request).UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) {
payload.GenericOauth = nil
}))
}),
@@ -282,7 +235,7 @@ func TestBuildRequest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- client := &argusClientMocked{
+ client := &observabilityClientMocked{
getGrafanaConfigsFails: tt.getGrafanaConfigsFails,
getGrafanaConfigsResp: tt.getGrafanaConfigsResp,
}
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
new file mode 100644
index 000000000..bf45ec5df
--- /dev/null
+++ b/internal/cmd/observability/grafana/public-read-access/public_read_access.go
@@ -0,0 +1,34 @@
+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/utils"
+
+ "github.com/spf13/cobra"
+)
+
+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",
+ Long: fmt.Sprintf("%s\n%s",
+ "Enable or disable public read access for Grafana in Observability instances.",
+ "When enabled, anyone can access the Grafana dashboards of the instance without logging in. Otherwise, a login is required.",
+ ),
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, params *types.CmdParams) {
+ cmd.AddCommand(enable.NewCmd(params))
+ cmd.AddCommand(disable.NewCmd(params))
+}
diff --git a/internal/cmd/argus/grafana/single-sign-on/disable/disable.go b/internal/cmd/observability/grafana/single-sign-on/disable/disable.go
similarity index 58%
rename from internal/cmd/argus/grafana/single-sign-on/disable/disable.go
rename to internal/cmd/observability/grafana/single-sign-on/disable/disable.go
index d41b1461d..57d7d7b8b 100644
--- a/internal/cmd/argus/grafana/single-sign-on/disable/disable.go
+++ b/internal/cmd/observability/grafana/single-sign-on/disable/disable.go
@@ -4,17 +4,19 @@ 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/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -26,44 +28,42 @@ 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 Argus instances",
+ Short: "Disables single sign-on for Grafana on Observability instances",
Long: fmt.Sprintf("%s\n%s",
- "Disables single sign-on for Grafana on Argus instances.",
+ "Disables single sign-on for Grafana on Observability instances.",
"When disabled for an instance, the generic OAuth2 authentication is used for that instance.",
),
Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
Example: examples.Build(
examples.NewExample(
- `Disable single sign-on for Grafana on an Argus instance with ID "xxx"`,
- "$ stackit argus grafana single-sign-on disable xxx"),
+ `Disable single sign-on for Grafana on an Observability instance with ID "xxx"`,
+ "$ stackit observability grafana single-sign-on disable 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
}
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
if err != nil || instanceLabel == "" {
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 +76,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,21 +96,13 @@ 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 argusUtils.ArgusClient) (argus.ApiUpdateGrafanaConfigsRequest, error) {
+func buildRequest(ctx context.Context, model *inputModel, apiClient observabilityUtils.ObservabilityClient) (observability.ApiUpdateGrafanaConfigsRequest, error) {
req := apiClient.UpdateGrafanaConfigs(ctx, model.InstanceId, model.ProjectId)
- payload, err := argusUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, utils.Ptr(false), nil)
+ payload, err := observabilityUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, utils.Ptr(false), nil)
if err != nil {
return req, fmt.Errorf("build request payload: %w", err)
}
diff --git a/internal/cmd/argus/grafana/single-sign-on/disable/disable_test.go b/internal/cmd/observability/grafana/single-sign-on/disable/disable_test.go
similarity index 67%
rename from internal/cmd/argus/grafana/single-sign-on/disable/disable_test.go
rename to internal/cmd/observability/grafana/single-sign-on/disable/disable_test.go
index e4f9f3f4b..224c73cca 100644
--- a/internal/cmd/argus/grafana/single-sign-on/disable/disable_test.go
+++ b/internal/cmd/observability/grafana/single-sign-on/disable/disable_test.go
@@ -6,14 +6,14 @@ import (
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/argus"
+ observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -21,24 +21,24 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
-type argusClientMocked struct {
+type observabilityClientMocked struct {
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
}
-func (c *argusClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) {
+func (c *observabilityClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*observability.GetInstanceResponse, error) {
return testClient.GetInstanceExecute(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) argus.ApiUpdateGrafanaConfigsRequest {
+func (c *observabilityClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) observability.ApiUpdateGrafanaConfigsRequest {
return testClient.UpdateGrafanaConfigs(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*argus.GrafanaConfigs, error) {
+func (c *observabilityClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*observability.GrafanaConfigs, error) {
if c.getGrafanaConfigsFails {
return nil, fmt.Errorf("get payload failed")
}
@@ -79,9 +79,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.GrafanaConfigs {
- gc := argus.GrafanaConfigs{
- GenericOauth: &argus.GrafanaOauth{
+func fixtureGrafanaConfigs(mods ...func(gc *observability.GrafanaConfigs)) *observability.GrafanaConfigs {
+ gc := observability.GrafanaConfigs{
+ GenericOauth: &observability.GrafanaOauth{
ApiUrl: utils.Ptr("apiUrl"),
AuthUrl: utils.Ptr("authUrl"),
Enabled: utils.Ptr(true),
@@ -103,9 +103,9 @@ func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.Grafan
return &gc
}
-func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *argus.UpdateGrafanaConfigsPayload {
- payload := &argus.UpdateGrafanaConfigsPayload{
- GenericOauth: argusUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
+func fixturePayload(mods ...func(payload *observability.UpdateGrafanaConfigsPayload)) *observability.UpdateGrafanaConfigsPayload {
+ payload := &observability.UpdateGrafanaConfigsPayload{
+ GenericOauth: observabilityUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
PublicReadAccess: fixtureGrafanaConfigs().PublicReadAccess,
UseStackitSso: utils.Ptr(false),
}
@@ -115,7 +115,7 @@ func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *a
return payload
}
-func fixtureRequest(mods ...func(request *argus.ApiUpdateGrafanaConfigsRequest)) argus.ApiUpdateGrafanaConfigsRequest {
+func fixtureRequest(mods ...func(request *observability.ApiUpdateGrafanaConfigsRequest)) observability.ApiUpdateGrafanaConfigsRequest {
request := testClient.UpdateGrafanaConfigs(testCtx, testInstanceId, testProjectId)
request = request.UpdateGrafanaConfigsPayload(*fixturePayload())
for _, mod := range mods {
@@ -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)
})
}
}
@@ -242,9 +195,9 @@ func TestBuildRequest(t *testing.T) {
description string
model *inputModel
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
isValid bool
- expectedRequest argus.ApiUpdateGrafanaConfigsRequest
+ expectedRequest observability.ApiUpdateGrafanaConfigsRequest
}{
{
description: "base",
@@ -256,12 +209,12 @@ func TestBuildRequest(t *testing.T) {
{
description: "nil generic oauth",
model: fixtureInputModel(),
- getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *argus.GrafanaConfigs) {
+ getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *observability.GrafanaConfigs) {
gc.GenericOauth = nil
}),
isValid: true,
- expectedRequest: fixtureRequest(func(request *argus.ApiUpdateGrafanaConfigsRequest) {
- *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *argus.UpdateGrafanaConfigsPayload) {
+ expectedRequest: fixtureRequest(func(request *observability.ApiUpdateGrafanaConfigsRequest) {
+ *request = (*request).UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) {
payload.GenericOauth = nil
}))
}),
@@ -282,7 +235,7 @@ func TestBuildRequest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- client := &argusClientMocked{
+ client := &observabilityClientMocked{
getGrafanaConfigsFails: tt.getGrafanaConfigsFails,
getGrafanaConfigsResp: tt.getGrafanaConfigsResp,
}
diff --git a/internal/cmd/argus/grafana/single-sign-on/enable/enable.go b/internal/cmd/observability/grafana/single-sign-on/enable/enable.go
similarity index 58%
rename from internal/cmd/argus/grafana/single-sign-on/enable/enable.go
rename to internal/cmd/observability/grafana/single-sign-on/enable/enable.go
index 4fa204fb3..3aca5bbe8 100644
--- a/internal/cmd/argus/grafana/single-sign-on/enable/enable.go
+++ b/internal/cmd/observability/grafana/single-sign-on/enable/enable.go
@@ -4,17 +4,19 @@ 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/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -26,44 +28,42 @@ 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 Argus instances",
+ Short: "Enables single sign-on for Grafana on Observability instances",
Long: fmt.Sprintf("%s\n%s",
- "Enables single sign-on for Grafana on Argus instances.",
+ "Enables single sign-on for Grafana on Observability instances.",
"When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.",
),
Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
Example: examples.Build(
examples.NewExample(
- `Enable single sign-on for Grafana on an Argus instance with ID "xxx"`,
- "$ stackit argus grafana single-sign-on enable xxx"),
+ `Enable single sign-on for Grafana on an Observability instance with ID "xxx"`,
+ "$ stackit observability grafana single-sign-on enable 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
}
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ instanceLabel, err := observabilityUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
if err != nil || instanceLabel == "" {
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,21 +96,13 @@ 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 argusUtils.ArgusClient) (argus.ApiUpdateGrafanaConfigsRequest, error) {
+func buildRequest(ctx context.Context, model *inputModel, apiClient observabilityUtils.ObservabilityClient) (observability.ApiUpdateGrafanaConfigsRequest, error) {
req := apiClient.UpdateGrafanaConfigs(ctx, model.InstanceId, model.ProjectId)
- payload, err := argusUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, utils.Ptr(true), nil)
+ payload, err := observabilityUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, utils.Ptr(true), nil)
if err != nil {
return req, fmt.Errorf("build request payload: %w", err)
}
diff --git a/internal/cmd/argus/grafana/single-sign-on/enable/enable_test.go b/internal/cmd/observability/grafana/single-sign-on/enable/enable_test.go
similarity index 67%
rename from internal/cmd/argus/grafana/single-sign-on/enable/enable_test.go
rename to internal/cmd/observability/grafana/single-sign-on/enable/enable_test.go
index 9b2c6b1d2..f13a2db25 100644
--- a/internal/cmd/argus/grafana/single-sign-on/enable/enable_test.go
+++ b/internal/cmd/observability/grafana/single-sign-on/enable/enable_test.go
@@ -6,14 +6,14 @@ import (
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ 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"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -21,24 +21,24 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
-type argusClientMocked struct {
+type observabilityClientMocked struct {
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
}
-func (c *argusClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) {
+func (c *observabilityClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*observability.GetInstanceResponse, error) {
return testClient.GetInstanceExecute(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) argus.ApiUpdateGrafanaConfigsRequest {
+func (c *observabilityClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) observability.ApiUpdateGrafanaConfigsRequest {
return testClient.UpdateGrafanaConfigs(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*argus.GrafanaConfigs, error) {
+func (c *observabilityClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*observability.GrafanaConfigs, error) {
if c.getGrafanaConfigsFails {
return nil, fmt.Errorf("get payload failed")
}
@@ -79,9 +79,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.GrafanaConfigs {
- gc := argus.GrafanaConfigs{
- GenericOauth: &argus.GrafanaOauth{
+func fixtureGrafanaConfigs(mods ...func(gc *observability.GrafanaConfigs)) *observability.GrafanaConfigs {
+ gc := observability.GrafanaConfigs{
+ GenericOauth: &observability.GrafanaOauth{
ApiUrl: utils.Ptr("apiUrl"),
AuthUrl: utils.Ptr("authUrl"),
Enabled: utils.Ptr(true),
@@ -103,9 +103,9 @@ func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.Grafan
return &gc
}
-func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *argus.UpdateGrafanaConfigsPayload {
- payload := &argus.UpdateGrafanaConfigsPayload{
- GenericOauth: argusUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
+func fixturePayload(mods ...func(payload *observability.UpdateGrafanaConfigsPayload)) *observability.UpdateGrafanaConfigsPayload {
+ payload := &observability.UpdateGrafanaConfigsPayload{
+ GenericOauth: observabilityUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
PublicReadAccess: fixtureGrafanaConfigs().PublicReadAccess,
UseStackitSso: utils.Ptr(true),
}
@@ -115,7 +115,7 @@ func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *a
return payload
}
-func fixtureRequest(mods ...func(request *argus.ApiUpdateGrafanaConfigsRequest)) argus.ApiUpdateGrafanaConfigsRequest {
+func fixtureRequest(mods ...func(request *observability.ApiUpdateGrafanaConfigsRequest)) observability.ApiUpdateGrafanaConfigsRequest {
request := testClient.UpdateGrafanaConfigs(testCtx, testInstanceId, testProjectId)
request = request.UpdateGrafanaConfigsPayload(*fixturePayload())
for _, mod := range mods {
@@ -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)
})
}
}
@@ -242,9 +195,9 @@ func TestBuildRequest(t *testing.T) {
description string
model *inputModel
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
isValid bool
- expectedRequest argus.ApiUpdateGrafanaConfigsRequest
+ expectedRequest observability.ApiUpdateGrafanaConfigsRequest
}{
{
description: "base",
@@ -256,12 +209,12 @@ func TestBuildRequest(t *testing.T) {
{
description: "nil generic oauth",
model: fixtureInputModel(),
- getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *argus.GrafanaConfigs) {
+ getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *observability.GrafanaConfigs) {
gc.GenericOauth = nil
}),
isValid: true,
- expectedRequest: fixtureRequest(func(request *argus.ApiUpdateGrafanaConfigsRequest) {
- *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *argus.UpdateGrafanaConfigsPayload) {
+ expectedRequest: fixtureRequest(func(request *observability.ApiUpdateGrafanaConfigsRequest) {
+ *request = (*request).UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *observability.UpdateGrafanaConfigsPayload) {
payload.GenericOauth = nil
}))
}),
@@ -282,7 +235,7 @@ func TestBuildRequest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- client := &argusClientMocked{
+ client := &observabilityClientMocked{
getGrafanaConfigsFails: tt.getGrafanaConfigsFails,
getGrafanaConfigsResp: tt.getGrafanaConfigsResp,
}
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
new file mode 100644
index 000000000..293066b8f
--- /dev/null
+++ b/internal/cmd/observability/grafana/single-sign-on/single_sign_on.go
@@ -0,0 +1,35 @@
+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/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "single-sign-on",
+ Aliases: []string{"sso"},
+ Short: "Enable or disable single sign-on for Grafana in Observability instances",
+ Long: fmt.Sprintf("%s\n%s",
+ "Enable or disable single sign-on for Grafana in Observability instances.",
+ "When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.",
+ ),
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+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
new file mode 100644
index 000000000..c492902e5
--- /dev/null
+++ b/internal/cmd/observability/instance/create/create.go
@@ -0,0 +1,206 @@
+package create
+
+import (
+ "context"
+ "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"
+ "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/observability/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "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"
+)
+
+const (
+ instanceNameFlag = "name"
+ planIdFlag = "plan-id"
+ planNameFlag = "plan-name"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ PlanName string
+
+ InstanceName *string
+ PlanId *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates an Observability instance",
+ Long: "Creates an Observability instance.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create an Observability instance with name "my-instance" and specify plan by name`,
+ "$ stackit observability instance create --name my-instance --plan-name Monitoring-Starter-EU01"),
+ examples.NewExample(
+ `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, 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 an Observability instance for project %q?", projectLabel)
+ err = params.Printer.PromptForConfirmation(prompt)
+ if err != nil {
+ return err
+ }
+
+ // Call API
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ var observabilityInvalidPlanError *cliErr.ObservabilityInvalidPlanError
+ if !errors.As(err, &observabilityInvalidPlanError) {
+ return fmt.Errorf("build Observability instance creation request: %w", err)
+ }
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create Observability instance: %w", err)
+ }
+ instanceId := *resp.InstanceId
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Creating instance")
+ _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, instanceId, model.ProjectId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for Observability instance creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name")
+ cmd.Flags().Var(flags.UUIDFlag(), planIdFlag, "Plan ID")
+ cmd.Flags().String(planNameFlag, "", "Plan name")
+
+ err := flags.MarkFlagsRequired(cmd, instanceNameFlag)
+ 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{}
+ }
+
+ planId := flags.FlagToStringPointer(p, cmd, planIdFlag)
+ planName := flags.FlagToStringValue(p, cmd, planNameFlag)
+
+ if planId == nil && (planName == "") {
+ return nil, &cliErr.ObservabilityInputPlanError{
+ Cmd: cmd,
+ }
+ }
+ if planId != nil && (planName != "") {
+ return nil, &cliErr.ObservabilityInputPlanError{
+ Cmd: cmd,
+ }
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ InstanceName: flags.FlagToStringPointer(p, cmd, instanceNameFlag),
+ PlanId: planId,
+ PlanName: planName,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+type observabilityClient interface {
+ CreateInstance(ctx context.Context, projectId string) observability.ApiCreateInstanceRequest
+ ListPlansExecute(ctx context.Context, projectId string) (*observability.PlansResponse, error)
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient observabilityClient) (observability.ApiCreateInstanceRequest, error) {
+ req := apiClient.CreateInstance(ctx, model.ProjectId)
+
+ var planId *string
+ var err error
+
+ plans, err := apiClient.ListPlansExecute(ctx, model.ProjectId)
+ if err != nil {
+ return req, fmt.Errorf("get Observability plans: %w", err)
+ }
+
+ if model.PlanId == nil {
+ planId, err = observabilityUtils.LoadPlanId(model.PlanName, plans)
+ if err != nil {
+ var observabilityInvalidPlanError *cliErr.ObservabilityInvalidPlanError
+ if !errors.As(err, &observabilityInvalidPlanError) {
+ return req, fmt.Errorf("load plan ID: %w", err)
+ }
+ return req, err
+ }
+ } else {
+ err := observabilityUtils.ValidatePlanId(*model.PlanId, plans)
+ if err != nil {
+ return req, err
+ }
+ planId = model.PlanId
+ }
+
+ req = req.CreateInstancePayload(observability.CreateInstancePayload{
+ Name: model.InstanceName,
+ PlanId: planId,
+ })
+ return req, nil
+}
+
+func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, resp *observability.CreateInstanceResponse) error {
+ if resp == nil {
+ return fmt.Errorf("resp is empty")
+ }
+
+ 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/argus/instance/create/create_test.go b/internal/cmd/observability/instance/create/create_test.go
similarity index 74%
rename from internal/cmd/argus/instance/create/create_test.go
rename to internal/cmd/observability/instance/create/create_test.go
index eb4b3c2c1..5aac98b82 100644
--- a/internal/cmd/argus/instance/create/create_test.go
+++ b/internal/cmd/observability/instance/create/create_test.go
@@ -5,14 +5,17 @@ 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/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -20,18 +23,18 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
-type argusClientMocked struct {
+type observabilityClientMocked struct {
returnError bool
- listPlansResponse *argus.PlansResponse
+ listPlansResponse *observability.PlansResponse
}
-func (c *argusClientMocked) CreateInstance(ctx context.Context, projectId string) argus.ApiCreateInstanceRequest {
+func (c *observabilityClientMocked) CreateInstance(ctx context.Context, projectId string) observability.ApiCreateInstanceRequest {
return testClient.CreateInstance(ctx, projectId)
}
-func (c *argusClientMocked) ListPlansExecute(_ context.Context, _ string) (*argus.PlansResponse, error) {
+func (c *observabilityClientMocked) ListPlansExecute(_ context.Context, _ string) (*observability.PlansResponse, error) {
if c.returnError {
return nil, fmt.Errorf("list plans failed")
}
@@ -68,9 +71,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiCreateInstanceRequest)) argus.ApiCreateInstanceRequest {
+func fixtureRequest(mods ...func(request *observability.ApiCreateInstanceRequest)) observability.ApiCreateInstanceRequest {
request := testClient.CreateInstance(testCtx, testProjectId)
- request = request.CreateInstancePayload(argus.CreateInstancePayload{
+ request = request.CreateInstancePayload(observability.CreateInstancePayload{
Name: utils.Ptr("example-name"),
PlanId: utils.Ptr(testPlanId),
})
@@ -80,9 +83,9 @@ func fixtureRequest(mods ...func(request *argus.ApiCreateInstanceRequest)) argus
return request
}
-func fixturePlansResponse(mods ...func(response *argus.PlansResponse)) *argus.PlansResponse {
- response := &argus.PlansResponse{
- Plans: &[]argus.Plan{
+func fixturePlansResponse(mods ...func(response *observability.PlansResponse)) *observability.PlansResponse {
+ response := &observability.PlansResponse{
+ Plans: &[]observability.Plan{
{
Name: utils.Ptr("example-plan-name"),
Id: utils.Ptr(testPlanId),
@@ -98,6 +101,7 @@ func fixturePlansResponse(mods ...func(response *argus.PlansResponse)) *argus.Pl
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)
})
}
}
@@ -222,9 +187,9 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiCreateInstanceRequest
+ expectedRequest observability.ApiCreateInstanceRequest
getPlansFails bool
- getPlansResponse *argus.PlansResponse
+ getPlansResponse *observability.PlansResponse
isValid bool
}{
{
@@ -287,7 +252,7 @@ func TestBuildRequest(t *testing.T) {
),
getPlansResponse: fixturePlansResponse(),
expectedRequest: testClient.CreateInstance(testCtx, testProjectId).
- CreateInstancePayload(argus.CreateInstancePayload{PlanId: utils.Ptr(testPlanId)}),
+ CreateInstancePayload(observability.CreateInstancePayload{PlanId: utils.Ptr(testPlanId)}),
isValid: true,
},
{
@@ -301,14 +266,14 @@ func TestBuildRequest(t *testing.T) {
),
getPlansResponse: fixturePlansResponse(),
expectedRequest: testClient.CreateInstance(testCtx, testProjectId).
- CreateInstancePayload(argus.CreateInstancePayload{PlanId: utils.Ptr(testPlanId)}),
+ CreateInstancePayload(observability.CreateInstancePayload{PlanId: utils.Ptr(testPlanId)}),
isValid: true,
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- client := &argusClientMocked{
+ client := &observabilityClientMocked{
returnError: tt.getPlansFails,
listPlansResponse: tt.getPlansResponse,
}
@@ -334,3 +299,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ projectLabel string
+ resp *observability.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty response",
+ args: args{
+ resp: &observability.CreateInstanceResponse{},
+ },
+ 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/argus/instance/delete/delete.go b/internal/cmd/observability/instance/delete/delete.go
similarity index 58%
rename from internal/cmd/argus/instance/delete/delete.go
rename to internal/cmd/observability/instance/delete/delete.go
index bb361c570..0aec201da 100644
--- a/internal/cmd/argus/instance/delete/delete.go
+++ b/internal/cmd/observability/instance/delete/delete.go
@@ -4,19 +4,21 @@ 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/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
- "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/spinner"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
- "github.com/stackitcloud/stackit-sdk-go/services/argus/wait"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability/wait"
)
const (
@@ -28,58 +30,56 @@ 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 Argus instance",
- Long: "Deletes an Argus instance.",
+ Short: "Deletes an Observability instance",
+ Long: "Deletes an Observability instance.",
Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
Example: examples.Build(
examples.NewExample(
- `Delete an Argus instance with ID "xxx"`,
- "$ stackit argus instance delete xxx"),
+ `Delete an Observability instance with ID "xxx"`,
+ "$ stackit Observability instance delete 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
}
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ 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
req := buildRequest(ctx, model, apiClient)
_, err = req.Execute()
if err != nil {
- return fmt.Errorf("delete Argus instance: %w", err)
+ return fmt.Errorf("delete Observability instance: %w", err)
}
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
_, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.InstanceId, model.ProjectId).WaitWithContext(ctx)
if err != nil {
- return fmt.Errorf("wait for Argus instance deletion: %w", err)
+ return fmt.Errorf("wait for Observability instance deletion: %w", err)
}
s.Stop()
}
@@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 *argus.APIClient) argus.ApiDeleteInstanceRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiDeleteInstanceRequest {
req := apiClient.DeleteInstance(ctx, model.InstanceId, model.ProjectId)
return req
}
diff --git a/internal/cmd/observability/instance/delete/delete_test.go b/internal/cmd/observability/instance/delete/delete_test.go
new file mode 100644
index 000000000..5d6bce774
--- /dev/null
+++ b/internal/cmd/observability/instance/delete/delete_test.go
@@ -0,0 +1,171 @@
+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/observability"
+)
+
+var projectIdFlag = globalflags.ProjectIdFlag
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &observability.APIClient{}
+var testProjectId = uuid.NewString()
+var testInstanceId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testInstanceId,
+ }
+ for _, mod := range mods {
+ mod(argValues)
+ }
+ return argValues
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ projectIdFlag: testProjectId,
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ InstanceId: testInstanceId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *observability.ApiDeleteInstanceRequest)) observability.ApiDeleteInstanceRequest {
+ request := testClient.DeleteInstance(testCtx, testInstanceId, 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",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: []string{},
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "no flag values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, projectIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[projectIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "instance id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "instance id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ 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 observability.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/observability/instance/describe/describe.go b/internal/cmd/observability/instance/describe/describe.go
new file mode 100644
index 000000000..e9e4a256a
--- /dev/null
+++ b/internal/cmd/observability/instance/describe/describe.go
@@ -0,0 +1,130 @@
+package describe
+
+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/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
+)
+
+const (
+ instanceIdArg = "INSTANCE_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ InstanceId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", instanceIdArg),
+ Short: "Shows details of an Observability instance",
+ Long: "Shows details of an Observability instance.",
+ Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of an Observability instance with ID "xxx"`,
+ "$ stackit observability instance describe xxx"),
+ examples.NewExample(
+ `Get details of an Observability instance with ID "xxx" in JSON format`,
+ "$ stackit observability 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
+ }
+ // 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 Observability instance: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ 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 *observability.APIClient) observability.ApiGetInstanceRequest {
+ req := apiClient.GetInstance(ctx, model.InstanceId, model.ProjectId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, instance *observability.GetInstanceResponse) error {
+ if instance == nil {
+ return fmt.Errorf("instance is nil")
+ }
+
+ return p.OutputResult(outputFormat, instance, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(instance.Id))
+ table.AddSeparator()
+ table.AddRow("NAME", utils.PtrString(instance.Name))
+ table.AddSeparator()
+ table.AddRow("STATUS", utils.PtrString(instance.Status))
+ table.AddSeparator()
+ table.AddRow("PLAN NAME", utils.PtrString(instance.PlanName))
+ table.AddSeparator()
+ if inst := instance.Instance; inst != nil {
+ if plan := inst.Plan; plan != nil {
+ table.AddRow("METRIC SAMPLES (PER MIN)", utils.PtrString(plan.TotalMetricSamples))
+ table.AddSeparator()
+ table.AddRow("LOGS (GB)", utils.PtrString(plan.LogsStorage))
+ table.AddSeparator()
+ table.AddRow("TRACES (GB)", utils.PtrString(plan.TracesStorage))
+ table.AddSeparator()
+ table.AddRow("NOTIFICATION RULES", utils.PtrString(plan.AlertRules))
+ table.AddSeparator()
+ table.AddRow("GRAFANA USERS", utils.PtrString(plan.GrafanaGlobalUsers))
+ table.AddSeparator()
+ }
+ table.AddRow("GRAFANA URL", utils.PtrString(inst.GrafanaUrl))
+ table.AddSeparator()
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/argus/instance/describe/describe_test.go b/internal/cmd/observability/instance/describe/describe_test.go
similarity index 74%
rename from internal/cmd/argus/instance/describe/describe_test.go
rename to internal/cmd/observability/instance/describe/describe_test.go
index fd9b429b2..dd1c07d42 100644
--- a/internal/cmd/argus/instance/describe/describe_test.go
+++ b/internal/cmd/observability/instance/describe/describe_test.go
@@ -4,13 +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/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -18,7 +21,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
@@ -56,7 +59,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiGetInstanceRequest)) argus.ApiGetInstanceRequest {
+func fixtureRequest(mods ...func(request *observability.ApiGetInstanceRequest)) observability.ApiGetInstanceRequest {
request := testClient.GetInstance(testCtx, testInstanceId, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -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)
})
}
}
@@ -193,7 +149,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiGetInstanceRequest
+ expectedRequest observability.ApiGetInstanceRequest
}{
{
description: "base",
@@ -216,3 +172,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *observability.GetInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty instance",
+ args: args{
+ instance: &observability.GetInstanceResponse{},
+ },
+ 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.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/observability/instance/instance.go b/internal/cmd/observability/instance/instance.go
new file mode 100644
index 000000000..955ae39ec
--- /dev/null
+++ b/internal/cmd/observability/instance/instance.go
@@ -0,0 +1,34 @@
+package instance
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/instance/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/instance/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/instance/describe"
+ "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/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 Observability instances",
+ Long: "Provides functionality for Observability 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/argus/instance/list/list.go b/internal/cmd/observability/instance/list/list.go
similarity index 55%
rename from internal/cmd/argus/instance/list/list.go
rename to internal/cmd/observability/instance/list/list.go
index 98a8e74d5..2d31e348c 100644
--- a/internal/cmd/argus/instance/list/list.go
+++ b/internal/cmd/observability/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -13,11 +14,10 @@ import (
"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/argus/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -29,32 +29,32 @@ 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 Argus instances",
- Long: "Lists all Argus instances.",
+ Short: "Lists all Observability instances",
+ Long: "Lists all Observability instances.",
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
- `List all Argus instances`,
- "$ stackit argus instance list"),
+ `List all Observability instances`,
+ "$ stackit observability instance list"),
examples.NewExample(
- `List all Argus instances in JSON format`,
- "$ stackit argus instance list --output-format json"),
+ `List all Observability instances in JSON format`,
+ "$ stackit observability instance list --output-format json"),
examples.NewExample(
- `List up to 10 Argus instances`,
- "$ stackit argus instance list --limit 10"),
+ `List up to 10 Observability instances`,
+ "$ stackit observability instance list --limit 10"),
),
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
}
@@ -63,16 +63,16 @@ func NewCmd(p *print.Printer) *cobra.Command {
req := buildRequest(ctx, model, apiClient)
resp, err := req.Execute()
if err != nil {
- return fmt.Errorf("get Argus instances: %w", err)
+ return fmt.Errorf("get Observability instances: %w", err)
}
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 +81,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 +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,47 +112,27 @@ 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 *argus.APIClient) argus.ApiListInstancesRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiListInstancesRequest {
req := apiClient.ListInstances(ctx, model.ProjectId)
return req
}
-func outputResult(p *print.Printer, outputFormat string, instances []argus.ProjectInstanceFull) error {
- switch outputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(instances, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Argus instance list: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Argus instance list: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+func outputResult(p *print.Printer, outputFormat string, instances []observability.ProjectInstanceFull) error {
+ return p.OutputResult(outputFormat, instances, func() error {
table := tables.NewTable()
table.SetHeader("ID", "NAME", "PLAN", "STATUS")
for i := range instances {
instance := instances[i]
- table.AddRow(*instance.Id, *instance.Name, *instance.PlanName, *instance.Status)
+ table.AddRow(
+ utils.PtrString(instance.Id),
+ utils.PtrString(instance.Name),
+ utils.PtrString(instance.PlanName),
+ utils.PtrString(instance.Status),
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,5 +140,5 @@ func outputResult(p *print.Printer, outputFormat string, instances []argus.Proje
}
return nil
- }
+ })
}
diff --git a/internal/cmd/argus/instance/list/list_test.go b/internal/cmd/observability/instance/list/list_test.go
similarity index 69%
rename from internal/cmd/argus/instance/list/list_test.go
rename to internal/cmd/observability/instance/list/list_test.go
index 5e6e9cb9d..456dbfb20 100644
--- a/internal/cmd/argus/instance/list/list_test.go
+++ b/internal/cmd/observability/instance/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/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -20,7 +22,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
@@ -48,7 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiListInstancesRequest)) argus.ApiListInstancesRequest {
+func fixtureRequest(mods ...func(request *observability.ApiListInstancesRequest)) observability.ApiListInstancesRequest {
request := testClient.ListInstances(testCtx, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -59,6 +61,7 @@ func fixtureRequest(mods ...func(request *argus.ApiListInstancesRequest)) argus.
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)
})
}
}
@@ -162,7 +125,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiListInstancesRequest
+ expectedRequest observability.ApiListInstancesRequest
}{
{
description: "base",
@@ -185,3 +148,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instances []observability.ProjectInstanceFull
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty instances slice",
+ args: args{
+ instances: []observability.ProjectInstanceFull{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty instance in instances slice",
+ args: args{
+ instances: []observability.ProjectInstanceFull{{}},
+ },
+ 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.instances); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/argus/instance/update/update.go b/internal/cmd/observability/instance/update/update.go
similarity index 58%
rename from internal/cmd/argus/instance/update/update.go
rename to internal/cmd/observability/instance/update/update.go
index dbfe4bc13..51d0b9f56 100644
--- a/internal/cmd/argus/instance/update/update.go
+++ b/internal/cmd/observability/instance/update/update.go
@@ -5,20 +5,22 @@ 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"
"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/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/spinner"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
- "github.com/stackitcloud/stackit-sdk-go/services/argus/wait"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability/wait"
)
const (
@@ -38,73 +40,71 @@ 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 Argus instance",
- Long: "Updates an Argus instance.",
+ Short: "Updates an Observability instance",
+ Long: "Updates an Observability instance.",
Args: args.SingleArg(instanceIdArg, utils.ValidateUUID),
Example: examples.Build(
examples.NewExample(
- `Update the plan of an Argus instance with ID "xxx" by specifying the plan ID`,
- "$ stackit argus instance update xxx --plan-id yyy"),
+ `Update the plan of an Observability instance with ID "xxx" by specifying the plan ID`,
+ "$ stackit observability instance update xxx --plan-id yyy"),
examples.NewExample(
- `Update the plan of an Argus instance with ID "xxx" by specifying the plan name`,
- "$ stackit argus instance update xxx --plan-name Frontend-Starter-EU01"),
+ `Update the plan of an Observability instance with ID "xxx" by specifying the plan name`,
+ "$ stackit observability instance update xxx --plan-name Frontend-Starter-EU01"),
examples.NewExample(
- `Update the name of an Argus instance with ID "xxx"`,
- "$ stackit argus instance update xxx --name new-instance-name"),
+ `Update the name of an Observability instance with ID "xxx"`,
+ "$ stackit observability instance update xxx --name new-instance-name"),
),
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 := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ 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
req, err := buildRequest(ctx, model, apiClient)
if err != nil {
- var argusInvalidPlanError *cliErr.ArgusInvalidPlanError
- if !errors.As(err, &argusInvalidPlanError) {
- return fmt.Errorf("build Argus instance update request: %w", err)
+ var observabilityInvalidPlanError *cliErr.ObservabilityInvalidPlanError
+ if !errors.As(err, &observabilityInvalidPlanError) {
+ return fmt.Errorf("build Observability instance update request: %w", err)
}
return err
}
_, err = req.Execute()
if err != nil {
- return fmt.Errorf("update Argus instance: %w", err)
+ return fmt.Errorf("update Observability instance: %w", err)
}
instanceId := model.InstanceId
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Updating instance")
_, err = wait.UpdateInstanceWaitHandler(ctx, apiClient, instanceId, model.ProjectId).WaitWithContext(ctx)
if err != nil {
- return fmt.Errorf("wait for Argus instance update: %w", err)
+ return fmt.Errorf("wait for Observability instance update: %w", err)
}
s.Stop()
}
@@ -113,7 +113,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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
},
}
@@ -140,7 +140,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
instanceName := flags.FlagToStringPointer(p, cmd, instanceNameFlag)
if planId != nil && (planName != "") {
- return nil, &cliErr.ArgusInputPlanError{
+ return nil, &cliErr.ObservabilityInputPlanError{
Cmd: cmd,
}
}
@@ -157,58 +157,50 @@ 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
}
-type argusClient interface {
- UpdateInstance(ctx context.Context, instanceId, projectId string) argus.ApiUpdateInstanceRequest
- ListPlansExecute(ctx context.Context, projectId string) (*argus.PlansResponse, error)
- GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error)
+type observabilityClient interface {
+ UpdateInstance(ctx context.Context, instanceId, projectId string) observability.ApiUpdateInstanceRequest
+ ListPlansExecute(ctx context.Context, projectId string) (*observability.PlansResponse, error)
+ GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*observability.GetInstanceResponse, error)
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient argusClient) (argus.ApiUpdateInstanceRequest, error) {
+func buildRequest(ctx context.Context, model *inputModel, apiClient observabilityClient) (observability.ApiUpdateInstanceRequest, error) {
req := apiClient.UpdateInstance(ctx, model.InstanceId, model.ProjectId)
var err error
plans, err := apiClient.ListPlansExecute(ctx, model.ProjectId)
if err != nil {
- return req, fmt.Errorf("get Argus plans: %w", err)
+ return req, fmt.Errorf("get Observability plans: %w", err)
}
currentInstance, err := apiClient.GetInstanceExecute(ctx, model.InstanceId, model.ProjectId)
if err != nil {
- return req, fmt.Errorf("get Argus instance: %w", err)
+ return req, fmt.Errorf("get Observability instance: %w", err)
}
- payload := argus.UpdateInstancePayload{
+ payload := observability.UpdateInstancePayload{
PlanId: currentInstance.PlanId,
Name: currentInstance.Name,
}
if model.PlanId == nil && model.PlanName != "" {
- payload.PlanId, err = argusUtils.LoadPlanId(model.PlanName, plans)
+ payload.PlanId, err = observabilityUtils.LoadPlanId(model.PlanName, plans)
if err != nil {
- var argusInvalidPlanError *cliErr.ArgusInvalidPlanError
- if !errors.As(err, &argusInvalidPlanError) {
+ var observabilityInvalidPlanError *cliErr.ObservabilityInvalidPlanError
+ if !errors.As(err, &observabilityInvalidPlanError) {
return req, fmt.Errorf("load plan ID: %w", err)
}
return req, err
}
} else if model.PlanId != nil && model.PlanName == "" {
- err := argusUtils.ValidatePlanId(*model.PlanId, plans)
+ err := observabilityUtils.ValidatePlanId(*model.PlanId, plans)
if err != nil {
- var argusInvalidPlanError *cliErr.ArgusInvalidPlanError
- if !errors.As(err, &argusInvalidPlanError) {
+ var observabilityInvalidPlanError *cliErr.ObservabilityInvalidPlanError
+ if !errors.As(err, &observabilityInvalidPlanError) {
return req, fmt.Errorf("validate plan ID: %w", err)
}
return req, err
diff --git a/internal/cmd/argus/instance/update/update_test.go b/internal/cmd/observability/instance/update/update_test.go
similarity index 79%
rename from internal/cmd/argus/instance/update/update_test.go
rename to internal/cmd/observability/instance/update/update_test.go
index 89a7f99ca..6a576ce5d 100644
--- a/internal/cmd/argus/instance/update/update_test.go
+++ b/internal/cmd/observability/instance/update/update_test.go
@@ -6,13 +6,13 @@ 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"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -20,27 +20,27 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
-type argusClientMocked struct {
+type observabilityClientMocked struct {
listPlansError bool
- listPlansResponse *argus.PlansResponse
+ listPlansResponse *observability.PlansResponse
getInstanceError bool
- getInstanceResponse *argus.GetInstanceResponse
+ getInstanceResponse *observability.GetInstanceResponse
}
-func (c *argusClientMocked) UpdateInstance(ctx context.Context, instanceId, projectId string) argus.ApiUpdateInstanceRequest {
+func (c *observabilityClientMocked) UpdateInstance(ctx context.Context, instanceId, projectId string) observability.ApiUpdateInstanceRequest {
return testClient.UpdateInstance(ctx, instanceId, projectId)
}
-func (c *argusClientMocked) ListPlansExecute(_ context.Context, _ string) (*argus.PlansResponse, error) {
+func (c *observabilityClientMocked) ListPlansExecute(_ context.Context, _ string) (*observability.PlansResponse, error) {
if c.listPlansError {
return nil, fmt.Errorf("list flavors failed")
}
return c.listPlansResponse, nil
}
-func (c *argusClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*argus.GetInstanceResponse, error) {
+func (c *observabilityClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*observability.GetInstanceResponse, error) {
if c.getInstanceError {
return nil, fmt.Errorf("get instance failed")
}
@@ -94,9 +94,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiUpdateInstanceRequest)) argus.ApiUpdateInstanceRequest {
+func fixtureRequest(mods ...func(request *observability.ApiUpdateInstanceRequest)) observability.ApiUpdateInstanceRequest {
request := testClient.UpdateInstance(testCtx, testInstanceId, testProjectId)
- request = request.UpdateInstancePayload(argus.UpdateInstancePayload{
+ request = request.UpdateInstancePayload(observability.UpdateInstancePayload{
PlanId: utils.Ptr(testNewPlanId),
Name: utils.Ptr(testInstanceName),
})
@@ -106,9 +106,9 @@ func fixtureRequest(mods ...func(request *argus.ApiUpdateInstanceRequest)) argus
return request
}
-func fixturePlansResponse(mods ...func(response *argus.PlansResponse)) *argus.PlansResponse {
- response := &argus.PlansResponse{
- Plans: &[]argus.Plan{
+func fixturePlansResponse(mods ...func(response *observability.PlansResponse)) *observability.PlansResponse {
+ response := &observability.PlansResponse{
+ Plans: &[]observability.Plan{
{
Name: utils.Ptr("example-plan-name"),
Id: utils.Ptr(testNewPlanId),
@@ -121,8 +121,8 @@ func fixturePlansResponse(mods ...func(response *argus.PlansResponse)) *argus.Pl
return response
}
-func fixtureGetInstanceResponse(mods ...func(response *argus.GetInstanceResponse)) *argus.GetInstanceResponse {
- response := &argus.GetInstanceResponse{
+func fixtureGetInstanceResponse(mods ...func(response *observability.GetInstanceResponse)) *observability.GetInstanceResponse {
+ response := &observability.GetInstanceResponse{
PlanId: utils.Ptr(testPlanId),
Name: utils.Ptr(testInstanceName),
}
@@ -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)
})
}
}
@@ -295,11 +248,11 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiUpdateInstanceRequest
+ expectedRequest observability.ApiUpdateInstanceRequest
getPlansFails bool
- getPlansResponse *argus.PlansResponse
+ getPlansResponse *observability.PlansResponse
getInstanceFails bool
- getInstanceResponse *argus.GetInstanceResponse
+ getInstanceResponse *observability.GetInstanceResponse
isValid bool
}{
{
@@ -394,7 +347,7 @@ func TestBuildRequest(t *testing.T) {
),
getInstanceResponse: fixtureGetInstanceResponse(),
expectedRequest: fixtureRequest().
- UpdateInstancePayload(argus.UpdateInstancePayload{
+ UpdateInstancePayload(observability.UpdateInstancePayload{
PlanId: utils.Ptr(testPlanId),
Name: utils.Ptr("new-instance-name"),
}),
@@ -416,7 +369,7 @@ func TestBuildRequest(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- client := &argusClientMocked{
+ client := &observabilityClientMocked{
listPlansError: tt.getPlansFails,
listPlansResponse: tt.getPlansResponse,
getInstanceError: tt.getInstanceFails,
diff --git a/internal/cmd/observability/observability.go b/internal/cmd/observability/observability.go
new file mode 100644
index 000000000..66345691a
--- /dev/null
+++ b/internal/cmd/observability/observability.go
@@ -0,0 +1,34 @@
+package observability
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/credentials"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/grafana"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/instance"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "observability",
+ Short: "Provides functionality for Observability",
+ Long: "Provides functionality for Observability.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+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/argus/plans/plans.go b/internal/cmd/observability/plans/plans.go
similarity index 56%
rename from internal/cmd/argus/plans/plans.go
rename to internal/cmd/observability/plans/plans.go
index a80800177..5ef3952ed 100644
--- a/internal/cmd/argus/plans/plans.go
+++ b/internal/cmd/observability/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"
@@ -13,11 +13,12 @@ import (
"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/argus/client"
+ "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/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -29,32 +30,32 @@ 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 Argus service plans",
- Long: "Lists all Argus service plans.",
+ Short: "Lists all Observability service plans",
+ Long: "Lists all Observability service plans.",
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
- `List all Argus service plans`,
- "$ stackit argus plans"),
+ `List all Observability service plans`,
+ "$ stackit observability plans"),
examples.NewExample(
- `List all Argus service plans in JSON format`,
- "$ stackit argus plans --output-format json"),
+ `List all Observability service plans in JSON format`,
+ "$ stackit observability plans --output-format json"),
examples.NewExample(
- `List up to 10 Argus service plans`,
- "$ stackit argus plans --limit 10"),
+ `List up to 10 Observability service plans`,
+ "$ stackit observability plans --limit 10"),
),
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
}
@@ -63,16 +64,16 @@ func NewCmd(p *print.Printer) *cobra.Command {
req := buildRequest(ctx, model, apiClient)
resp, err := req.Execute()
if err != nil {
- return fmt.Errorf("get Argus service plans: %w", err)
+ return fmt.Errorf("get Observability service plans: %w", err)
}
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
}
@@ -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,47 +113,26 @@ 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 *argus.APIClient) argus.ApiListPlansRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiListPlansRequest {
req := apiClient.ListPlans(ctx, model.ProjectId)
return req
}
-func outputResult(p *print.Printer, outputFormat string, plans []argus.Plan) error {
- switch outputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(plans, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Argus plans: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(plans, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Argus plans: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+func outputResult(p *print.Printer, outputFormat string, plans []observability.Plan) error {
+ return p.OutputResult(outputFormat, plans, func() error {
table := tables.NewTable()
table.SetHeader("ID", "PLAN NAME", "DESCRIPTION")
for i := range plans {
o := plans[i]
- table.AddRow(*o.Id, *o.Name, *o.Description)
+ table.AddRow(
+ utils.PtrString(o.Id),
+ utils.PtrString(o.Name),
+ utils.PtrString(o.Description),
+ )
table.AddSeparator()
}
table.EnableAutoMergeOnColumns(1)
@@ -162,5 +142,5 @@ func outputResult(p *print.Printer, outputFormat string, plans []argus.Plan) err
}
return nil
- }
+ })
}
diff --git a/internal/cmd/argus/plans/plans_test.go b/internal/cmd/observability/plans/plans_test.go
similarity index 70%
rename from internal/cmd/argus/plans/plans_test.go
rename to internal/cmd/observability/plans/plans_test.go
index d23fe8ec1..65fb129d3 100644
--- a/internal/cmd/argus/plans/plans_test.go
+++ b/internal/cmd/observability/plans/plans_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/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -20,7 +22,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
@@ -48,7 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiListPlansRequest)) argus.ApiListPlansRequest {
+func fixtureRequest(mods ...func(request *observability.ApiListPlansRequest)) observability.ApiListPlansRequest {
request := testClient.ListPlans(testCtx, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -59,6 +61,7 @@ func fixtureRequest(mods ...func(request *argus.ApiListPlansRequest)) argus.ApiL
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)
})
}
}
@@ -163,7 +125,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiListPlansRequest
+ expectedRequest observability.ApiListPlansRequest
}{
{
description: "base",
@@ -186,3 +148,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ plans []observability.Plan
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty plans slice",
+ args: args{
+ plans: []observability.Plan{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty plan in plans slice",
+ args: args{
+ plans: []observability.Plan{{}},
+ },
+ 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.plans); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/argus/scrape-config/create/create.go b/internal/cmd/observability/scrape-config/create/create.go
similarity index 58%
rename from internal/cmd/argus/scrape-config/create/create.go
rename to internal/cmd/observability/scrape-config/create/create.go
index a2c4d02e5..f3e9a0515 100644
--- a/internal/cmd/argus/scrape-config/create/create.go
+++ b/internal/cmd/observability/scrape-config/create/create.go
@@ -5,19 +5,22 @@ 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"
"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/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/spinner"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
- "github.com/stackitcloud/stackit-sdk-go/services/argus/wait"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability/wait"
)
const (
@@ -28,15 +31,15 @@ const (
type inputModel struct {
*globalflags.GlobalFlagModel
InstanceId string
- Payload *argus.CreateScrapeConfigPayload
+ 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 Argus instance",
+ Short: "Creates a scrape configuration for an Observability instance",
Long: fmt.Sprintf("%s\n%s\n%s\n%s",
- "Creates a scrape configuration job for an Argus instance.",
+ "Creates a scrape configuration job for an Observability instance.",
"The payload can be provided as a JSON string or a file path prefixed with \"@\".",
"If no payload is provided, a default payload will be used.",
"See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_create for information regarding the payload structure.",
@@ -44,54 +47,52 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
- `Create a scrape configuration on Argus instance "xxx" using default configuration`,
- "$ stackit argus scrape-config create"),
+ `Create a scrape configuration on Observability instance "xxx" using default configuration`,
+ "$ stackit observability scrape-config create"),
examples.NewExample(
- `Create a scrape configuration on Argus instance "xxx" using an API payload sourced from the file "./payload.json"`,
- "$ stackit argus scrape-config create --payload @./payload.json --instance-id xxx"),
+ `Create a scrape configuration on Observability instance "xxx" using an API payload sourced from the file "./payload.json"`,
+ "$ stackit observability scrape-config create --payload @./payload.json --instance-id xxx"),
examples.NewExample(
- `Create a scrape configuration on Argus instance "xxx" using an API payload provided as a JSON string`,
- `$ stackit argus scrape-config create --payload "{...}" --instance-id xxx`),
+ `Create a scrape configuration on Observability instance "xxx" using an API payload provided as a JSON string`,
+ `$ stackit observability scrape-config create --payload "{...}" --instance-id xxx`),
examples.NewExample(
`Generate a payload with default values, and adapt it with custom values for the different configuration options`,
- `$ stackit argus scrape-config generate-payload > ./payload.json`,
+ `$ stackit observability scrape-config generate-payload > ./payload.json`,
``,
- `$ stackit argus scrape-config create --payload @./payload.json --instance-id xxx`),
+ `$ stackit observability scrape-config create --payload @./payload.json --instance-id xxx`),
),
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 := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ 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
}
// Fill in default payload, if needed
if model.Payload == nil {
- defaultPayload := argusUtils.DefaultCreateScrapeConfigPayload
+ defaultPayload := observabilityUtils.DefaultCreateScrapeConfigPayload
if err != nil {
return fmt.Errorf("get default payload: %w", err)
}
model.Payload = &defaultPayload
}
- if !model.AssumeYes {
- prompt := fmt.Sprintf("Are you sure you want to create scrape configuration %q on Argus 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
@@ -105,7 +106,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating scrape config")
_, err = wait.CreateScrapeConfigWaitHandler(ctx, apiClient, model.InstanceId, *jobName, model.ProjectId).WaitWithContext(ctx)
if err != nil {
@@ -118,7 +119,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
if model.Async {
operationState = "Triggered creation of"
}
- p.Outputf("%s scrape configuration with name %q for Argus instance %q\n", operationState, *jobName, instanceLabel)
+ params.Printer.Outputf("%s scrape configuration with name %q for Observability instance %q\n", operationState, utils.PtrString(jobName), instanceLabel)
return nil
},
}
@@ -127,23 +128,23 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
func configureFlags(cmd *cobra.Command) {
- cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit argus scrape-config generate-payload")`)
+ cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit observability scrape-config generate-payload")`)
cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID")
err := flags.MarkFlagsRequired(cmd, instanceIdFlag)
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{}
}
payloadValue := flags.FlagToStringPointer(p, cmd, payloadFlag)
- var payload *argus.CreateScrapeConfigPayload
+ var payload *observability.CreateScrapeConfigPayload
if payloadValue != nil {
- payload = &argus.CreateScrapeConfigPayload{}
+ payload = &observability.CreateScrapeConfigPayload{}
err := json.Unmarshal([]byte(*payloadValue), payload)
if err != nil {
return nil, fmt.Errorf("encode payload: %w", err)
@@ -157,7 +158,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
}, nil
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiCreateScrapeConfigRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiCreateScrapeConfigRequest {
req := apiClient.CreateScrapeConfig(ctx, model.InstanceId, model.ProjectId)
req = req.CreateScrapeConfigPayload(*model.Payload)
diff --git a/internal/cmd/argus/scrape-config/create/create_test.go b/internal/cmd/observability/scrape-config/create/create_test.go
similarity index 76%
rename from internal/cmd/argus/scrape-config/create/create_test.go
rename to internal/cmd/observability/scrape-config/create/create_test.go
index a9ca561b4..9d4ab9ba9 100644
--- a/internal/cmd/argus/scrape-config/create/create_test.go
+++ b/internal/cmd/observability/scrape-config/create/create_test.go
@@ -2,15 +2,18 @@ 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"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -18,12 +21,12 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
-var testPayload = &argus.CreateScrapeConfigPayload{
- BasicAuth: &argus.CreateScrapeConfigPayloadBasicAuth{
+var testPayload = &observability.CreateScrapeConfigPayload{
+ BasicAuth: &observability.CreateScrapeConfigPayloadBasicAuth{
Username: utils.Ptr("username"),
Password: utils.Ptr("password"),
},
@@ -32,9 +35,9 @@ var testPayload = &argus.CreateScrapeConfigPayload{
HonorTimeStamps: utils.Ptr(true),
MetricsPath: utils.Ptr("/metrics"),
JobName: utils.Ptr("default-name"),
- MetricsRelabelConfigs: &[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
+ MetricsRelabelConfigs: &[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
{
- Action: utils.Ptr("replace"),
+ Action: observability.CREATESCRAPECONFIGPAYLOADMETRICSRELABELCONFIGSINNERACTION_REPLACE.Ptr(),
Modulus: utils.Ptr(1.0),
Regex: utils.Ptr("regex"),
Replacement: utils.Ptr("replacement"),
@@ -48,10 +51,10 @@ var testPayload = &argus.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: &[]argus.CreateScrapeConfigPayloadStaticConfigsInner{
+ StaticConfigs: &[]observability.CreateScrapeConfigPayloadStaticConfigsInner{
{
Labels: &map[string]interface{}{
"label": "value",
@@ -60,7 +63,7 @@ var testPayload = &argus.CreateScrapeConfigPayload{
Targets: &[]string{"target"},
},
},
- TlsConfig: &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{
+ TlsConfig: &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{
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)
@@ -133,7 +136,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiCreateScrapeConfigRequest)) argus.ApiCreateScrapeConfigRequest {
+func fixtureRequest(mods ...func(request *observability.ApiCreateScrapeConfigRequest)) observability.ApiCreateScrapeConfigRequest {
request := testClient.CreateScrapeConfig(testCtx, testInstanceId, testProjectId)
request = request.CreateScrapeConfigPayload(*testPayload)
for _, mod := range mods {
@@ -145,6 +148,7 @@ func fixtureRequest(mods ...func(request *argus.ApiCreateScrapeConfigRequest)) a
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)
})
}
}
@@ -286,7 +242,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiCreateScrapeConfigRequest
+ expectedRequest observability.ApiCreateScrapeConfigRequest
isValid bool
}{
{
diff --git a/internal/cmd/argus/scrape-config/delete/delete.go b/internal/cmd/observability/scrape-config/delete/delete.go
similarity index 63%
rename from internal/cmd/argus/scrape-config/delete/delete.go
rename to internal/cmd/observability/scrape-config/delete/delete.go
index 31cc6d855..4cd15229b 100644
--- a/internal/cmd/argus/scrape-config/delete/delete.go
+++ b/internal/cmd/observability/scrape-config/delete/delete.go
@@ -4,19 +4,21 @@ 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/services/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "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/spinner"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
- "github.com/stackitcloud/stackit-sdk-go/services/argus/wait"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability/wait"
)
const (
@@ -31,42 +33,40 @@ 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 Argus instance",
- Long: "Deletes a scrape configuration from an Argus instance.",
+ Short: "Deletes a scrape configuration from an Observability instance",
+ Long: "Deletes a scrape configuration from an Observability instance.",
Args: args.SingleArg(jobNameArg, nil),
Example: examples.Build(
examples.NewExample(
- `Delete a scrape configuration job with name "my-config" from Argus instance "xxx"`,
- "$ stackit argus scrape-config delete my-config --instance-id xxx"),
+ `Delete a scrape configuration job with name "my-config" from Observability instance "xxx"`,
+ "$ stackit observability scrape-config delete my-config --instance-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
}
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ 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 Argus 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,7 +78,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting scrape config")
_, err = wait.DeleteScrapeConfigWaitHandler(ctx, apiClient, model.InstanceId, model.JobName, model.ProjectId).WaitWithContext(ctx)
if err != nil {
@@ -91,7 +91,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
if model.Async {
operationState = "Triggered deletion of"
}
- p.Info("%s scrape configuration with name %q for Argus 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
},
}
@@ -121,7 +121,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
}, nil
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiDeleteScrapeConfigRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiDeleteScrapeConfigRequest {
req := apiClient.DeleteScrapeConfig(ctx, model.InstanceId, model.JobName, model.ProjectId)
return req
}
diff --git a/internal/cmd/argus/scrape-config/delete/delete_test.go b/internal/cmd/observability/scrape-config/delete/delete_test.go
similarity index 94%
rename from internal/cmd/argus/scrape-config/delete/delete_test.go
rename to internal/cmd/observability/scrape-config/delete/delete_test.go
index 21d1b79c9..c9549964d 100644
--- a/internal/cmd/argus/scrape-config/delete/delete_test.go
+++ b/internal/cmd/observability/scrape-config/delete/delete_test.go
@@ -9,7 +9,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/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -17,7 +17,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testJobName = "my-config"
@@ -58,7 +58,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiDeleteScrapeConfigRequest)) argus.ApiDeleteScrapeConfigRequest {
+func fixtureRequest(mods ...func(request *observability.ApiDeleteScrapeConfigRequest)) observability.ApiDeleteScrapeConfigRequest {
request := testClient.DeleteScrapeConfig(testCtx, testInstanceId, testJobName, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -207,7 +207,7 @@ func TestBuildRequest(t *testing.T) {
description string
model *inputModel
isValid bool
- expectedRequest argus.ApiDeleteScrapeConfigRequest
+ expectedRequest observability.ApiDeleteScrapeConfigRequest
}{
{
description: "base",
diff --git a/internal/cmd/argus/scrape-config/describe/describe.go b/internal/cmd/observability/scrape-config/describe/describe.go
similarity index 57%
rename from internal/cmd/argus/scrape-config/describe/describe.go
rename to internal/cmd/observability/scrape-config/describe/describe.go
index 2b9d1fb99..91dc0d064 100644
--- a/internal/cmd/argus/scrape-config/describe/describe.go
+++ b/internal/cmd/observability/scrape-config/describe/describe.go
@@ -2,22 +2,22 @@ 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-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/argus/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -32,28 +32,28 @@ 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 Argus instance",
- Long: "Shows details of a scrape configuration from an Argus instance.",
+ Short: "Shows details of a scrape configuration from an Observability instance",
+ Long: "Shows details of a scrape configuration from an Observability instance.",
Args: args.SingleArg(jobNameArg, nil),
Example: examples.Build(
examples.NewExample(
- `Get details of a scrape configuration with name "my-config" from Argus instance "xxx"`,
- "$ stackit argus scrape-config describe my-config --instance-id xxx"),
+ `Get details of a scrape configuration with name "my-config" from Observability instance "xxx"`,
+ "$ stackit observability scrape-config describe my-config --instance-id xxx"),
examples.NewExample(
- `Get details of a scrape configuration with name "my-config" from Argus instance "xxx" in JSON format`,
- "$ stackit argus scrape-config describe my-config --output-format json"),
+ `Get details of a scrape configuration with name "my-config" from Observability instance "xxx" in JSON format`,
+ "$ stackit observability scrape-config describe my-config --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
}
@@ -65,7 +65,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)
@@ -94,30 +94,17 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
}, nil
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiGetScrapeConfigRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiGetScrapeConfigRequest {
req := apiClient.GetScrapeConfig(ctx, model.InstanceId, model.JobName, model.ProjectId)
return req
}
-func outputResult(p *print.Printer, outputFormat string, config *argus.Job) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal scrape configuration: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, config *observability.Job) error {
+ if config == nil {
+ return fmt.Errorf(`config is nil`)
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, config, func() error {
saml2Enabled := "Enabled"
if config.Params != nil {
saml2 := (*config.Params)["saml2"]
@@ -127,35 +114,37 @@ func outputResult(p *print.Printer, outputFormat string, config *argus.Job) erro
}
var targets []string
- for _, target := range *config.StaticConfigs {
- targetLabels := []string{}
- targetLabelStr := "N/A"
- if target.Labels != nil {
- // make map prettier
- for k, v := range *target.Labels {
- targetLabels = append(targetLabels, fmt.Sprintf("%s:%s", k, v))
+ if config.StaticConfigs != nil {
+ for _, target := range *config.StaticConfigs {
+ targetLabels := []string{}
+ targetLabelStr := "N/A"
+ if target.Labels != nil {
+ // make map prettier
+ for k, v := range *target.Labels {
+ targetLabels = append(targetLabels, fmt.Sprintf("%s:%s", k, v))
+ }
+ if targetLabels != nil {
+ targetLabelStr = strings.Join(targetLabels, ",")
+ }
}
- if targetLabels != nil {
- targetLabelStr = strings.Join(targetLabels, ",")
+ targetUrlsStr := "N/A"
+ if target.Targets != nil {
+ targetUrlsStr = strings.Join(*target.Targets, ",")
}
+ targets = append(targets, fmt.Sprintf("labels: %s\nurls: %s", targetLabelStr, targetUrlsStr))
}
- targetUrlsStr := "N/A"
- if target.Targets != nil {
- targetUrlsStr = strings.Join(*target.Targets, ",")
- }
- targets = append(targets, fmt.Sprintf("labels: %s\nurls: %s", targetLabelStr, targetUrlsStr))
}
table := tables.NewTable()
- table.AddRow("NAME", *config.JobName)
+ table.AddRow("NAME", utils.PtrString(config.JobName))
table.AddSeparator()
- table.AddRow("METRICS PATH", *config.MetricsPath)
+ table.AddRow("METRICS PATH", utils.PtrString(config.MetricsPath))
table.AddSeparator()
- table.AddRow("SCHEME", *config.Scheme)
+ table.AddRow("SCHEME", utils.PtrString(config.Scheme))
table.AddSeparator()
- table.AddRow("SCRAPE INTERVAL", *config.ScrapeInterval)
+ table.AddRow("SCRAPE INTERVAL", utils.PtrString(config.ScrapeInterval))
table.AddSeparator()
- table.AddRow("SCRAPE TIMEOUT", *config.ScrapeTimeout)
+ table.AddRow("SCRAPE TIMEOUT", utils.PtrString(config.ScrapeTimeout))
table.AddSeparator()
table.AddRow("SAML2", saml2Enabled)
table.AddSeparator()
@@ -164,9 +153,9 @@ func outputResult(p *print.Printer, outputFormat string, config *argus.Job) erro
} else {
table.AddRow("AUTHENTICATION", "Basic Auth")
table.AddSeparator()
- table.AddRow("USERNAME", *config.BasicAuth.Username)
+ table.AddRow("USERNAME", utils.PtrString(config.BasicAuth.Username))
table.AddSeparator()
- table.AddRow("PASSWORD", *config.BasicAuth.Password)
+ table.AddRow("PASSWORD", utils.PtrString(config.BasicAuth.Password))
}
table.AddSeparator()
for i, target := range targets {
@@ -180,5 +169,5 @@ func outputResult(p *print.Printer, outputFormat string, config *argus.Job) erro
}
return nil
- }
+ })
}
diff --git a/internal/cmd/argus/scrape-config/describe/describe_test.go b/internal/cmd/observability/scrape-config/describe/describe_test.go
similarity index 82%
rename from internal/cmd/argus/scrape-config/describe/describe_test.go
rename to internal/cmd/observability/scrape-config/describe/describe_test.go
index 962d373a9..5f4326b33 100644
--- a/internal/cmd/argus/scrape-config/describe/describe_test.go
+++ b/internal/cmd/observability/scrape-config/describe/describe_test.go
@@ -4,12 +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/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -17,7 +20,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testJobName = "my-config"
@@ -58,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiGetScrapeConfigRequest)) argus.ApiGetScrapeConfigRequest {
+func fixtureRequest(mods ...func(request *observability.ApiGetScrapeConfigRequest)) observability.ApiGetScrapeConfigRequest {
request := testClient.GetScrapeConfig(testCtx, testInstanceId, testJobName, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -207,7 +210,7 @@ func TestBuildRequest(t *testing.T) {
description string
model *inputModel
isValid bool
- expectedRequest argus.ApiGetScrapeConfigRequest
+ expectedRequest observability.ApiGetScrapeConfigRequest
}{
{
description: "base",
@@ -231,3 +234,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ config *observability.Job
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty config",
+ args: args{
+ config: &observability.Job{},
+ },
+ 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.config); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/argus/scrape-config/generate-payload/generate_payload.go b/internal/cmd/observability/scrape-config/generate-payload/generate_payload.go
similarity index 68%
rename from internal/cmd/argus/scrape-config/generate-payload/generate_payload.go
rename to internal/cmd/observability/scrape-config/generate-payload/generate_payload.go
index 9d5e720f2..e891d728e 100644
--- a/internal/cmd/argus/scrape-config/generate-payload/generate_payload.go
+++ b/internal/cmd/observability/scrape-config/generate-payload/generate_payload.go
@@ -5,17 +5,19 @@ 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"
"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/argus/client"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client"
+ observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -31,14 +33,14 @@ 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 Argus instance ",
+ Short: "Generates a payload to create/update scrape configurations for an Observability instance ",
Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s\n",
"Generates a JSON payload with values to be used as --payload input for scrape configurations creation or update.",
"This command can be used to generate a payload to update an existing scrape config or to create a new scrape config job.",
- "To update an existing scrape config job, provide the job name and the instance ID of the Argus instance.",
+ "To update an existing scrape config job, provide the job name and the instance ID of the Observability instance.",
"To obtain a default payload to create a new scrape config job, run the command with no flags.",
"Note that some of the default values provided, such as the job name, the metrics path and URL of the targets, should be adapted to your use case.",
"See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_create for information regarding the payload structure.",
@@ -47,48 +49,48 @@ func NewCmd(p *print.Printer) *cobra.Command {
Example: examples.Build(
examples.NewExample(
`Generate a Create payload with default values, and adapt it with custom values for the different configuration options`,
- `$ stackit argus scrape-config generate-payload --file-path ./payload.json`,
+ `$ stackit observability scrape-config generate-payload --file-path ./payload.json`,
``,
- `$ stackit argus scrape-config create my-config --payload @./payload.json`),
+ `$ stackit observability scrape-config create my-config --payload @./payload.json`),
examples.NewExample(
- `Generate an Update payload with the values of an existing configuration named "my-config" for Argus instance xxx, and adapt it with custom values for the different configuration options`,
- `$ stackit argus scrape-config generate-payload --job-name my-config --instance-id xxx --file-path ./payload.json`,
+ `Generate an Update payload with the values of an existing configuration named "my-config" for Observability instance xxx, and adapt it with custom values for the different configuration options`,
+ `$ stackit observability scrape-config generate-payload --job-name my-config --instance-id xxx --file-path ./payload.json`,
``,
- `$ stackit argus scrape-config update my-config --payload @./payload.json`),
+ `$ stackit observability scrape-config update my-config --payload @./payload.json`),
examples.NewExample(
- `Generate an Update payload with the values of an existing configuration named "my-config" for Argus instance xxx, and preview it in the terminal`,
- `$ stackit argus scrape-config generate-payload --job-name my-config --instance-id xxx`),
+ `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, 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 := argusUtils.DefaultCreateScrapeConfigPayload
- return outputCreateResult(p, model.FilePath, &createPayload)
+ createPayload := observabilityUtils.DefaultCreateScrapeConfigPayload
+ return outputCreateResult(params.Printer, model.FilePath, &createPayload)
}
req := buildRequest(ctx, model, apiClient)
resp, err := req.Execute()
if err != nil {
- return fmt.Errorf("read Argus scrape config: %w", err)
+ return fmt.Errorf("read Observability scrape config: %w", err)
}
- payload, err := argusUtils.MapToUpdateScrapeConfigPayload(resp)
+ payload, err := observabilityUtils.MapToUpdateScrapeConfigPayload(resp)
if err != nil {
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)
@@ -119,12 +121,16 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
}, nil
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiGetScrapeConfigRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiGetScrapeConfigRequest {
req := apiClient.GetScrapeConfig(ctx, model.InstanceId, *model.JobName, model.ProjectId)
return req
}
-func outputCreateResult(p *print.Printer, filePath *string, payload *argus.CreateScrapeConfigPayload) error {
+func outputCreateResult(p *print.Printer, filePath *string, payload *observability.CreateScrapeConfigPayload) error {
+ if payload == nil {
+ return fmt.Errorf("payload is nil")
+ }
+
payloadBytes, err := json.MarshalIndent(*payload, "", " ")
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
@@ -142,7 +148,11 @@ func outputCreateResult(p *print.Printer, filePath *string, payload *argus.Creat
return nil
}
-func outputUpdateResult(p *print.Printer, filePath *string, payload *argus.UpdateScrapeConfigPayload) error {
+func outputUpdateResult(p *print.Printer, filePath *string, payload *observability.UpdateScrapeConfigPayload) error {
+ if payload == nil {
+ return fmt.Errorf("payload is nil")
+ }
+
payloadBytes, err := json.MarshalIndent(*payload, "", " ")
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
diff --git a/internal/cmd/argus/scrape-config/generate-payload/generate_payload_test.go b/internal/cmd/observability/scrape-config/generate-payload/generate_payload_test.go
similarity index 70%
rename from internal/cmd/argus/scrape-config/generate-payload/generate_payload_test.go
rename to internal/cmd/observability/scrape-config/generate-payload/generate_payload_test.go
index 5c04abd5c..81d3138e3 100644
--- a/internal/cmd/argus/scrape-config/generate-payload/generate_payload_test.go
+++ b/internal/cmd/observability/scrape-config/generate-payload/generate_payload_test.go
@@ -4,13 +4,18 @@ 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"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -18,7 +23,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
@@ -56,7 +61,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiGetScrapeConfigRequest)) argus.ApiGetScrapeConfigRequest {
+func fixtureRequest(mods ...func(request *observability.ApiGetScrapeConfigRequest)) observability.ApiGetScrapeConfigRequest {
request := testClient.GetScrapeConfig(testCtx, testInstanceId, testJobName, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -67,6 +72,7 @@ func fixtureRequest(mods ...func(request *argus.ApiGetScrapeConfigRequest)) argu
func TestParseInput(t *testing.T) {
tests := []struct {
description string
+ argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
@@ -169,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)
})
}
}
@@ -224,7 +184,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiGetScrapeConfigRequest
+ expectedRequest observability.ApiGetScrapeConfigRequest
isValid bool
}{
{
@@ -248,3 +208,71 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputCreateResult(t *testing.T) {
+ type args struct {
+ filePath *string
+ payload *observability.CreateScrapeConfigPayload
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty payload",
+ args: args{
+ payload: &observability.CreateScrapeConfigPayload{},
+ },
+ 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 := outputCreateResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr {
+ t.Errorf("outputCreateResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestOutputUpdateResult(t *testing.T) {
+ type args struct {
+ filePath *string
+ payload *observability.UpdateScrapeConfigPayload
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty payload",
+ args: args{
+ payload: &observability.UpdateScrapeConfigPayload{},
+ },
+ 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 := outputUpdateResult(p, tt.args.filePath, tt.args.payload); (err != nil) != tt.wantErr {
+ t.Errorf("outputUpdateResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/argus/scrape-config/list/list.go b/internal/cmd/observability/scrape-config/list/list.go
similarity index 59%
rename from internal/cmd/argus/scrape-config/list/list.go
rename to internal/cmd/observability/scrape-config/list/list.go
index 60f6598a0..faab36b0a 100644
--- a/internal/cmd/argus/scrape-config/list/list.go
+++ b/internal/cmd/observability/scrape-config/list/list.go
@@ -2,23 +2,24 @@ 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"
"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/argus/client"
+ "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"
- argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils"
+ observabilityUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/utils"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -32,32 +33,32 @@ 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 Argus instance",
- Long: "Lists all scrape configurations of an Argus instance.",
+ Short: "Lists all scrape configurations of an Observability instance",
+ Long: "Lists all scrape configurations of an Observability instance.",
Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
- `List all scrape configurations of Argus instance "xxx"`,
- "$ stackit argus scrape-config list --instance-id xxx"),
+ `List all scrape configurations of Observability instance "xxx"`,
+ "$ stackit observability scrape-config list --instance-id xxx"),
examples.NewExample(
- `List all scrape configurations of Argus instance "xxx" in JSON format`,
- "$ stackit argus scrape-config list --instance-id xxx --output-format json"),
+ `List all scrape configurations of Observability instance "xxx" in JSON format`,
+ "$ stackit observability scrape-config list --instance-id xxx --output-format json"),
examples.NewExample(
- `List up to 10 scrape configurations of Argus instance "xxx"`,
- "$ stackit argus scrape-config list --instance-id xxx --limit 10"),
+ `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, 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 {
}
configs := *resp.Data
if len(configs) == 0 {
- instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId)
+ 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
}
@@ -84,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)
},
}
@@ -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{}
@@ -121,30 +122,13 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
}, nil
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiListScrapeConfigsRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiListScrapeConfigsRequest {
req := apiClient.ListScrapeConfigs(ctx, model.InstanceId, model.ProjectId)
return req
}
-func outputResult(p *print.Printer, outputFormat string, configs []argus.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))
- if err != nil {
- return fmt.Errorf("marshal scrape configurations list: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+func outputResult(p *print.Printer, outputFormat string, configs []observability.Job) error {
+ return p.OutputResult(outputFormat, configs, func() error {
table := tables.NewTable()
table.SetHeader("NAME", "TARGETS", "SCRAPE INTERVAL")
for i := range configs {
@@ -160,7 +144,11 @@ func outputResult(p *print.Printer, outputFormat string, configs []argus.Job) er
}
}
- table.AddRow(*c.JobName, targets, *c.ScrapeInterval)
+ table.AddRow(
+ utils.PtrString(c.JobName),
+ targets,
+ utils.PtrString(c.ScrapeInterval),
+ )
}
err := table.Display(p)
if err != nil {
@@ -168,5 +156,5 @@ func outputResult(p *print.Printer, outputFormat string, configs []argus.Job) er
}
return nil
- }
+ })
}
diff --git a/internal/cmd/argus/scrape-config/list/list_test.go b/internal/cmd/observability/scrape-config/list/list_test.go
similarity index 72%
rename from internal/cmd/argus/scrape-config/list/list_test.go
rename to internal/cmd/observability/scrape-config/list/list_test.go
index a62605da2..6d4569d71 100644
--- a/internal/cmd/argus/scrape-config/list/list_test.go
+++ b/internal/cmd/observability/scrape-config/list/list_test.go
@@ -4,14 +4,18 @@ 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"
"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/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -19,7 +23,7 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
@@ -50,7 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiListScrapeConfigsRequest)) argus.ApiListScrapeConfigsRequest {
+func fixtureRequest(mods ...func(request *observability.ApiListScrapeConfigsRequest)) observability.ApiListScrapeConfigsRequest {
request := testClient.ListScrapeConfigs(testCtx, testInstanceId, testProjectId)
for _, mod := range mods {
mod(&request)
@@ -61,6 +65,7 @@ func fixtureRequest(mods ...func(request *argus.ApiListScrapeConfigsRequest)) ar
func TestParseInput(t *testing.T) {
tests := []struct {
description string
+ argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
@@ -136,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)
})
}
}
@@ -185,7 +150,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiListScrapeConfigsRequest
+ expectedRequest observability.ApiListScrapeConfigsRequest
}{
{
description: "base",
@@ -208,3 +173,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ configs []observability.Job
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty configs slice",
+ args: args{
+ configs: []observability.Job{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty config in configs slice",
+ args: args{
+ configs: []observability.Job{{}},
+ },
+ 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.configs); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/observability/scrape-config/scrape_config.go b/internal/cmd/observability/scrape-config/scrape_config.go
new file mode 100644
index 000000000..b45cff386
--- /dev/null
+++ b/internal/cmd/observability/scrape-config/scrape_config.go
@@ -0,0 +1,36 @@
+package scrapeconfig
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/scrape-config/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/scrape-config/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability/scrape-config/describe"
+ generatepayload "github.com/stackitcloud/stackit-cli/internal/cmd/observability/scrape-config/generate-payload"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "scrape-config",
+ Short: "Provides functionality for scrape configurations in Observability",
+ Long: "Provides functionality for scrape configurations in Observability.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+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/argus/scrape-config/update/update.go b/internal/cmd/observability/scrape-config/update/update.go
similarity index 64%
rename from internal/cmd/argus/scrape-config/update/update.go
rename to internal/cmd/observability/scrape-config/update/update.go
index ef92943ce..130199713 100644
--- a/internal/cmd/argus/scrape-config/update/update.go
+++ b/internal/cmd/observability/scrape-config/update/update.go
@@ -5,16 +5,18 @@ 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"
"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/argus/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/observability/client"
"github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
@@ -28,51 +30,49 @@ type inputModel struct {
*globalflags.GlobalFlagModel
JobName string
InstanceId string
- Payload argus.UpdateScrapeConfigPayload
+ 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 Argus instance",
+ Short: "Updates a scrape configuration of an Observability instance",
Long: fmt.Sprintf("%s\n%s\n%s",
- "Updates a scrape configuration of an Argus instance.",
+ "Updates a scrape configuration of an Observability instance.",
"The payload can be provided as a JSON string or a file path prefixed with \"@\".",
"See https://docs.api.stackit.cloud/documentation/argus/version/v1#tag/scrape-config/operation/v1_projects_instances_scrapeconfigs_update for information regarding the payload structure.",
),
Args: args.SingleArg(jobNameArg, nil),
Example: examples.Build(
examples.NewExample(
- `Update a scrape configuration with name "my-config" from Argus instance "xxx", using an API payload sourced from the file "./payload.json"`,
- "$ stackit argus scrape-config update my-config --payload @./payload.json --instance-id xxx"),
+ `Update a scrape configuration with name "my-config" from Observability instance "xxx", using an API payload sourced from the file "./payload.json"`,
+ "$ stackit observability scrape-config update my-config --payload @./payload.json --instance-id xxx"),
examples.NewExample(
- `Update an scrape configuration with name "my-config" from Argus instance "xxx", using an API payload provided as a JSON string`,
- `$ stackit argus scrape-config update my-config --payload "{...}" --instance-id xxx`),
+ `Update an scrape configuration with name "my-config" from Observability instance "xxx", using an API payload provided as a JSON string`,
+ `$ stackit observability scrape-config update my-config --payload "{...}" --instance-id xxx`),
examples.NewExample(
`Generate a payload with the current values of a scrape configuration, and adapt it with custom values for the different configuration options`,
- `$ stackit argus scrape-config generate-payload --job-name my-config > ./payload.json`,
+ `$ stackit observability scrape-config generate-payload --job-name my-config > ./payload.json`,
``,
- `$ stackit argus scrape-configs update my-config --payload @./payload.json`),
+ `$ stackit observability scrape-configs update my-config --payload @./payload.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
}
- 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 Argus scrape configuration with name %q\n", model.JobName)
+ params.Printer.Info("Updated Observability scrape configuration with name %q\n", model.JobName)
return nil
},
}
@@ -108,7 +108,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
}
payloadString := flags.FlagToStringValue(p, cmd, payloadFlag)
- var payload argus.UpdateScrapeConfigPayload
+ var payload observability.UpdateScrapeConfigPayload
err := json.Unmarshal([]byte(payloadString), &payload)
if err != nil {
return nil, fmt.Errorf("encode payload: %w", err)
@@ -122,7 +122,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
}, nil
}
-func buildRequest(ctx context.Context, model *inputModel, apiClient *argus.APIClient) argus.ApiUpdateScrapeConfigRequest {
+func buildRequest(ctx context.Context, model *inputModel, apiClient *observability.APIClient) observability.ApiUpdateScrapeConfigRequest {
req := apiClient.UpdateScrapeConfig(ctx, model.InstanceId, model.JobName, model.ProjectId)
req = req.UpdateScrapeConfigPayload(model.Payload)
diff --git a/internal/cmd/argus/scrape-config/update/update_test.go b/internal/cmd/observability/scrape-config/update/update_test.go
similarity index 91%
rename from internal/cmd/argus/scrape-config/update/update_test.go
rename to internal/cmd/observability/scrape-config/update/update_test.go
index 232d798c5..595b4f09e 100644
--- a/internal/cmd/argus/scrape-config/update/update_test.go
+++ b/internal/cmd/observability/scrape-config/update/update_test.go
@@ -10,7 +10,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/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var projectIdFlag = globalflags.ProjectIdFlag
@@ -18,13 +18,13 @@ var projectIdFlag = globalflags.ProjectIdFlag
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &observability.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testJobName = "my-config"
-var testPayload = argus.UpdateScrapeConfigPayload{
- BasicAuth: &argus.CreateScrapeConfigPayloadBasicAuth{
+var testPayload = observability.UpdateScrapeConfigPayload{
+ BasicAuth: &observability.CreateScrapeConfigPayloadBasicAuth{
Username: utils.Ptr("username"),
Password: utils.Ptr("password"),
},
@@ -32,9 +32,9 @@ var testPayload = argus.UpdateScrapeConfigPayload{
HonorLabels: utils.Ptr(true),
HonorTimeStamps: utils.Ptr(true),
MetricsPath: utils.Ptr("/metrics"),
- MetricsRelabelConfigs: &[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
+ MetricsRelabelConfigs: &[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
{
- Action: utils.Ptr("replace"),
+ Action: observability.CREATESCRAPECONFIGPAYLOADMETRICSRELABELCONFIGSINNERACTION_REPLACE.Ptr(),
Modulus: utils.Ptr(1.0),
Regex: utils.Ptr("regex"),
Replacement: utils.Ptr("replacement"),
@@ -111,7 +111,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiUpdateScrapeConfigRequest)) argus.ApiUpdateScrapeConfigRequest {
+func fixtureRequest(mods ...func(request *observability.ApiUpdateScrapeConfigRequest)) observability.ApiUpdateScrapeConfigRequest {
request := testClient.UpdateScrapeConfig(testCtx, testInstanceId, testJobName, testProjectId)
request = request.UpdateScrapeConfigPayload(testPayload)
for _, mod := range mods {
@@ -274,7 +274,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiUpdateScrapeConfigRequest
+ expectedRequest observability.ApiUpdateScrapeConfigRequest
isValid bool
}{
{
diff --git a/internal/cmd/opensearch/credentials/create/create.go b/internal/cmd/opensearch/credentials/create/create.go
index eebf71bb8..06c5b87dd 100644
--- a/internal/cmd/opensearch/credentials/create/create.go
+++ b/internal/cmd/opensearch/credentials/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/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"
@@ -15,8 +16,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/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/opensearch"
)
@@ -31,7 +30,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",
@@ -47,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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
@@ -79,7 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("create OpenSearch credentials: %w", err)
}
- return outputResult(p, model, instanceLabel, resp)
+ return outputResult(params.Printer, model.OutputFormat, model.ShowPassword, instanceLabel, resp)
},
}
configureFlags(cmd)
@@ -94,7 +91,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 +103,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
}
@@ -123,41 +112,31 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *opensearch.
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *opensearch.CredentialsResponse) error {
- if !model.ShowPassword {
- resp.Raw.Credentials.Password = utils.Ptr("hidden")
+func outputResult(p *print.Printer, outputFormat string, showPassword bool, instanceLabel string, resp *opensearch.CredentialsResponse) error {
+ if resp == nil || resp.Raw == nil || resp.Raw.Credentials == nil || resp.Uri == nil {
+ return fmt.Errorf("response or response content is nil")
}
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal OpenSearch credentials: %w", err)
- }
- p.Outputln(string(details))
+ if !showPassword {
+ resp.Raw.Credentials.Password = utils.Ptr("hidden")
+ }
- return nil
- default:
- p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, *resp.Id) // The username field cannot be set by the user so we only display it if it's not returned empty
- username := *resp.Raw.Credentials.Username
- if username != "" {
- p.Outputf("Username: %s\n", *resp.Raw.Credentials.Username)
- }
- if !model.ShowPassword {
- p.Outputf("Password: \n")
- } else {
- p.Outputf("Password: %s\n", *resp.Raw.Credentials.Password)
+ 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 {
+ if username := resp.Raw.Credentials.Username; username != nil && *username != "" {
+ p.Outputf("Username: %s\n", *username)
+ }
+ if !showPassword {
+ p.Outputf("Password: \n")
+ } else {
+ p.Outputf("Password: %s\n", utils.PtrString(resp.Raw.Credentials.Password))
+ }
+ p.Outputf("Host: %s\n", utils.PtrString(resp.Raw.Credentials.Host))
+ p.Outputf("Port: %s\n", utils.PtrString(resp.Raw.Credentials.Port))
}
- p.Outputf("Host: %s\n", *resp.Raw.Credentials.Host)
- p.Outputf("Port: %d\n", *resp.Raw.Credentials.Port)
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 cd8559b64..0c768de74 100644
--- a/internal/cmd/opensearch/credentials/create/create_test.go
+++ b/internal/cmd/opensearch/credentials/create/create_test.go
@@ -4,17 +4,18 @@ 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/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/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 +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)
@@ -58,6 +59,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 +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,
},
@@ -129,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)
})
}
}
@@ -200,3 +163,83 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ showPassword bool
+ instanceLabel string
+ resp *opensearch.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set no raw in response",
+ args: args{
+ resp: &opensearch.CredentialsResponse{
+ Uri: utils.Ptr("https://opensearch.example.com"),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "set empty raw in response",
+ args: args{
+ resp: &opensearch.CredentialsResponse{
+ Raw: &opensearch.RawCredentials{},
+ Uri: utils.Ptr("https://opensearch.example.com"),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "set raw but no uri in response",
+ args: args{
+ resp: &opensearch.CredentialsResponse{
+ Raw: &opensearch.RawCredentials{
+ Credentials: &opensearch.Credentials{},
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "set uri but no raw in response",
+ args: args{
+ resp: &opensearch.CredentialsResponse{
+ Uri: utils.Ptr("https://opensearch.example.com"),
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "set response correctly",
+ args: args{
+ resp: &opensearch.CredentialsResponse{
+ Raw: &opensearch.RawCredentials{
+ Credentials: &opensearch.Credentials{},
+ },
+ Uri: utils.Ptr("https://opensearch.example.com"),
+ },
+ },
+ 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.showPassword, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 596ffc3eb..17ddd78f9 100644
--- a/internal/cmd/opensearch/credentials/describe/describe.go
+++ b/internal/cmd/opensearch/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 an OpenSearch 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 OpenSearch 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
}
@@ -112,41 +104,29 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *opensearch.
}
func outputResult(p *print.Printer, outputFormat string, credentials *opensearch.CredentialsResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal OpenSearch credentials: %w", err)
- }
- p.Outputln(string(details))
+ if credentials == nil {
+ return fmt.Errorf("credentials is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, credentials, func() error {
table := tables.NewTable()
- table.AddRow("ID", *credentials.Id)
+ table.AddRow("ID", utils.PtrString(credentials.Id))
table.AddSeparator()
// The username field cannot be set by the user so we only display it if it's not returned empty
- username := *credentials.Raw.Credentials.Username
- if username != "" {
- table.AddRow("USERNAME", *credentials.Raw.Credentials.Username)
+ if credentials.HasRaw() && credentials.Raw.Credentials != nil {
+ if username := credentials.Raw.Credentials.Username; username != nil && *username != "" {
+ table.AddRow("USERNAME", *username)
+ table.AddSeparator()
+ }
+ table.AddRow("PASSWORD", utils.PtrString(credentials.Raw.Credentials.Password))
table.AddSeparator()
+ table.AddRow("URI", utils.PtrString(credentials.Raw.Credentials.Uri))
}
- table.AddRow("PASSWORD", *credentials.Raw.Credentials.Password)
- table.AddSeparator()
- table.AddRow("URI", *credentials.Raw.Credentials.Uri)
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/opensearch/credentials/describe/describe_test.go b/internal/cmd/opensearch/credentials/describe/describe_test.go
index 30fb04ded..254912869 100644
--- a/internal/cmd/opensearch/credentials/describe/describe_test.go
+++ b/internal/cmd/opensearch/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"
@@ -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")
@@ -35,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)
@@ -104,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,
},
@@ -112,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,
},
@@ -120,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,
},
@@ -164,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)
})
}
}
@@ -243,3 +197,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials *opensearch.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty credentials",
+ args: args{
+ credentials: &opensearch.CredentialsResponse{},
+ },
+ 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.credentials); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/opensearch/credentials/list/list.go b/internal/cmd/opensearch/credentials/list/list.go
index e4baea08d..05f4e8ef8 100644
--- a/internal/cmd/opensearch/credentials/list/list.go
+++ b/internal/cmd/opensearch/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"
@@ -15,6 +15,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/opensearch"
@@ -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 an OpenSearch instance",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 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)
@@ -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,30 +125,18 @@ 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))
- 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 {
c := credentials[i]
- table.AddRow(*c.Id)
+ table.AddRow(utils.PtrString(c.Id))
}
err := table.Display(p)
if err != nil {
@@ -165,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 44336f34b..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)
})
}
}
@@ -207,3 +170,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ credentials []opensearch.CredentialsListItem
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty credentials slice",
+ args: args{
+ credentials: []opensearch.CredentialsListItem{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty credential in credentials slice",
+ args: args{
+ credentials: []opensearch.CredentialsListItem{{}},
+ },
+ 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.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 0433ce412..bc03d82dd 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",
@@ -76,29 +76,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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,7 +116,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating instance")
_, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -127,7 +125,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
s.Stop()
}
- return outputResult(p, model, 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
}
@@ -256,30 +246,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient openSearchCl
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel, instanceId string, resp *opensearch.CreateInstanceResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal OpenSearch instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel, instanceId string, resp *opensearch.CreateInstanceResponse) error {
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Created"
- if model.Async {
+ 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 7fd34113f..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)
})
}
}
@@ -488,3 +426,40 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ projectLabel string
+ instanceId string
+ resp *opensearch.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty response",
+ args: args{
+ resp: &opensearch.CreateInstanceResponse{},
+ },
+ 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.instanceId, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/opensearch/instance/delete/delete.go b/internal/cmd/opensearch/instance/delete/delete.go
index 40dc60461..e1cc328fb 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,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
_, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
if err != nil {
@@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 b1f06faff..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
}
@@ -100,41 +92,32 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *opensearch.
}
func outputResult(p *print.Printer, outputFormat string, instance *opensearch.Instance) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal OpenSearch instance: %w", err)
- }
- p.Outputln(string(details))
+ if instance == nil {
+ return fmt.Errorf("instance is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, instance, func() error {
table := tables.NewTable()
- table.AddRow("ID", *instance.InstanceId)
+ table.AddRow("ID", utils.PtrString(instance.InstanceId))
table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("LAST OPERATION TYPE", *instance.LastOperation.Type)
- table.AddSeparator()
- table.AddRow("LAST OPERATION STATE", *instance.LastOperation.State)
- table.AddSeparator()
- table.AddRow("PLAN ID", *instance.PlanId)
+ if instance.LastOperation != nil {
+ table.AddRow("LAST OPERATION TYPE", utils.PtrString(instance.LastOperation.Type))
+ table.AddSeparator()
+ table.AddRow("LAST OPERATION STATE", utils.PtrString(instance.LastOperation.State))
+ table.AddSeparator()
+ }
+ table.AddRow("PLAN ID", utils.PtrString(instance.PlanId))
// Only show ACL if it's present and not empty
- acl := (*instance.Parameters)[aclParameterKey]
- aclStr, ok := acl.(string)
- if ok {
- if aclStr != "" {
- table.AddSeparator()
- table.AddRow("ACL", aclStr)
+ if instance.Parameters != nil {
+ acl := (*instance.Parameters)[aclParameterKey]
+ aclStr, ok := acl.(string)
+ if ok {
+ if aclStr != "" {
+ table.AddSeparator()
+ table.AddRow("ACL", aclStr)
+ }
}
}
err := table.Display(p)
@@ -143,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 b7c27a2b7..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)
})
}
}
@@ -216,3 +170,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *opensearch.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set empty instance",
+ args: args{
+ instance: &opensearch.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.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 a08b7ed1c..0dacbcf6d 100644
--- a/internal/cmd/opensearch/instance/list/list.go
+++ b/internal/cmd/opensearch/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/opensearch/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/opensearch"
)
@@ -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 OpenSearch instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +65,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 +78,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 +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,15 +109,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,30 +118,23 @@ 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))
- 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 {
instance := instances[i]
- table.AddRow(*instance.InstanceId, *instance.Name, *instance.LastOperation.Type, *instance.LastOperation.State)
+ table.AddRow(
+ utils.PtrString(instance.InstanceId),
+ utils.PtrString(instance.Name),
+ utils.PtrString(instance.LastOperation.Type),
+ utils.PtrString(instance.LastOperation.State),
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,5 +142,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 4036a79b7..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)
})
}
}
@@ -186,3 +146,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ instances []opensearch.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty instances slice",
+ args: args{
+ instances: []opensearch.Instance{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty instance in instances slice",
+ args: args{
+ instances: []opensearch.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/opensearch/instance/update/update.go b/internal/cmd/opensearch/instance/update/update.go
index 57072b0a8..aa9b90eb5 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,7 +114,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Updating instance")
_, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -127,7 +127,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 6f8a4ab74..3f644c000 100644
--- a/internal/cmd/opensearch/plans/plans.go
+++ b/internal/cmd/opensearch/plans/plans.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/opensearch/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/opensearch"
)
@@ -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: "plans",
Short: "Lists all OpenSearch service plans",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +65,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 +78,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 +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,15 +109,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,32 +118,28 @@ 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))
- 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 {
o := plans[i]
- for j := range *o.Plans {
- plan := (*o.Plans)[j]
- table.AddRow(*o.Name, *o.Version, *plan.Id, *plan.Name, *plan.Description)
+ if o.Plans != nil {
+ for j := range *o.Plans {
+ plan := (*o.Plans)[j]
+ table.AddRow(
+ utils.PtrString(o.Name),
+ utils.PtrString(o.Version),
+ utils.PtrString(plan.Id),
+ utils.PtrString(plan.Name),
+ utils.PtrString(plan.Description),
+ )
+ }
}
table.AddSeparator()
}
@@ -165,5 +150,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 bff8c20ef..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)
})
}
}
@@ -186,3 +146,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ plans []opensearch.Offering
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty plans slice",
+ args: args{
+ plans: []opensearch.Offering{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty plan in plans slice",
+ args: args{
+ plans: []opensearch.Offering{{}},
+ },
+ 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.plans); (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 523c6ad22..db8754c5f 100644
--- a/internal/cmd/organization/member/add/add.go
+++ b/internal/cmd/organization/member/add/add.go
@@ -4,6 +4,9 @@ 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/examples"
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
@@ -11,8 +14,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/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
)
@@ -33,7 +34,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",
@@ -52,21 +53,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
}
@@ -78,7 +77,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
},
}
@@ -106,15 +105,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 8b68411cd..7e17796c6 100644
--- a/internal/cmd/organization/member/list/list.go
+++ b/internal/cmd/organization/member/list/list.go
@@ -2,11 +2,12 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ 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/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
)
@@ -38,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 an organization",
@@ -57,13 +57,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +76,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 +85,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
members = members[:*model.Limit]
}
- return outputResult(p, model, members)
+ return outputResult(params.Printer, model.OutputFormat, model.SortBy, members)
},
}
configureFlags(cmd)
@@ -104,7 +104,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 +123,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
}
@@ -143,9 +135,9 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *authorizati
return req
}
-func outputResult(p *print.Printer, model *inputModel, members []authorization.Member) error {
+func outputResult(p *print.Printer, outputFormat, sortBy string, members []authorization.Member) error {
sortFn := func(i, j int) bool {
- switch model.SortBy {
+ switch sortBy {
case "subject":
return *members[i].Subject < *members[j].Subject
case "role":
@@ -156,25 +148,7 @@ func outputResult(p *print.Printer, model *inputModel, members []authorization.M
}
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))
- 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 {
@@ -183,12 +157,13 @@ func outputResult(p *print.Printer, model *inputModel, members []authorization.M
if i > 0 && sortFn(i-1, i) {
table.AddSeparator()
}
- table.AddRow(*m.Subject, *m.Role)
+ table.AddRow(utils.PtrString(m.Subject), utils.PtrString(m.Role))
}
- if model.SortBy == "subject" {
+ switch sortBy {
+ case "subject":
table.EnableAutoMergeOnColumns(1)
- } else if model.SortBy == "role" {
+ case "role":
table.EnableAutoMergeOnColumns(2)
}
@@ -198,5 +173,5 @@ func outputResult(p *print.Printer, model *inputModel, members []authorization.M
}
return nil
- }
+ })
}
diff --git a/internal/cmd/organization/member/list/list_test.go b/internal/cmd/organization/member/list/list_test.go
index a740b961c..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)
})
}
}
@@ -204,3 +166,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ sortBy string
+ members []authorization.Member
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty members slice",
+ args: args{
+ members: []authorization.Member{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty role in roles slice",
+ args: args{
+ members: []authorization.Member{{}},
+ },
+ 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.sortBy, tt.args.members); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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..7a68177b7 100644
--- a/internal/cmd/organization/organization.go
+++ b/internal/cmd/organization/organization.go
@@ -3,16 +3,17 @@ package organization
import (
"fmt"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
"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 +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(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))
}
diff --git a/internal/cmd/organization/role/list/list.go b/internal/cmd/organization/role/list/list.go
index 68352bff1..bb59b28c5 100644
--- a/internal/cmd/organization/role/list/list.go
+++ b/internal/cmd/organization/role/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -14,8 +15,7 @@ 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/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
)
@@ -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 roles and permissions of an organization",
@@ -52,13 +52,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +71,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 +80,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 +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)
limit := flags.FlagToInt64Pointer(p, cmd, limitFlag)
@@ -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,34 +121,23 @@ 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))
- 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 {
r := roles[i]
- for j := range *r.Permissions {
- p := (*r.Permissions)[j]
- table.AddRow(*r.Name, *r.Description, *p.Name, *p.Description)
+ if r.Permissions != nil {
+ for j := range *r.Permissions {
+ p := (*r.Permissions)[j]
+ table.AddRow(
+ utils.PtrString(r.Name),
+ utils.PtrString(r.Description),
+ utils.PtrString(p.Name),
+ utils.PtrString(p.Description),
+ )
+ }
+ table.AddSeparator()
}
- table.AddSeparator()
}
table.EnableAutoMergeOnColumns(1, 2)
err := table.Display(p)
@@ -165,5 +146,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 684ea2e26..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)
})
}
}
@@ -167,3 +129,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ roles []authorization.Role
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty roles slice",
+ args: args{
+ roles: []authorization.Role{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "set empty role in roles slice",
+ args: args{
+ roles: []authorization.Role{{}},
+ },
+ 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 := outputRolesResult(p, tt.args.outputFormat, tt.args.roles); (err != nil) != tt.wantErr {
+ t.Errorf("outputRolesResult() error = %v, wantErr %v", err, 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 9e306e2b4..891b78888 100644
--- a/internal/cmd/postgresflex/backup/describe/describe.go
+++ b/internal/cmd/postgresflex/backup/describe/describe.go
@@ -2,13 +2,11 @@ package describe
import (
"context"
- "encoding/json"
"fmt"
-
"time"
- "github.com/goccy/go-yaml"
- "github.com/inhies/go-bytesize"
+ "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"
@@ -18,6 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"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"
)
@@ -38,7 +37,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",
@@ -54,13 +53,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
}
@@ -73,7 +72,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("describe backup for PostgreSQL Flex instance: %w", err)
}
- return outputResult(p, cmd, model.OutputFormat, *resp.Item)
+ return outputResult(params.Printer, model.OutputFormat, *resp.Item)
},
}
configureFlags(cmd)
@@ -103,43 +102,31 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiGetBackupRequest {
- req := apiClient.GetBackup(ctx, model.ProjectId, model.InstanceId, model.BackupId)
+ req := apiClient.GetBackup(ctx, model.ProjectId, model.Region, model.InstanceId, model.BackupId)
return req
}
-func outputResult(p *print.Printer, cmd *cobra.Command, outputFormat string, backup postgresflex.Backup) error {
- backupStartTime, err := time.Parse(time.RFC3339, *backup.StartTime)
+func outputResult(p *print.Printer, outputFormat string, backup postgresflex.Backup) error {
+ if backup.StartTime == nil || *backup.StartTime == "" {
+ return fmt.Errorf("start time not defined")
+ }
+ backupStartTime, err := time.Parse(time.RFC3339, utils.PtrString(backup.StartTime))
if err != nil {
return fmt.Errorf("parse backup start time: %w", err)
}
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)
- }
- cmd.Println(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(backup, yaml.IndentSequence(true))
- 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", *backup.Id)
+ table.AddRow("ID", utils.PtrString(backup.Id))
table.AddSeparator()
- table.AddRow("CREATED AT", *backup.StartTime)
+ table.AddRow("CREATED AT", utils.PtrString(backup.StartTime))
table.AddSeparator()
table.AddRow("EXPIRES AT", backupExpireDate)
table.AddSeparator()
- table.AddRow("BACKUP SIZE", bytesize.New(float64(*backup.Size)))
+
+ backupSize := utils.PtrByteSizeDefault(backup.Size, "n/a")
+ table.AddRow("BACKUP SIZE", backupSize)
err := table.Display(p)
if err != nil {
@@ -147,5 +134,5 @@ func outputResult(p *print.Printer, cmd *cobra.Command, outputFormat string, bac
}
return nil
- }
+ })
}
diff --git a/internal/cmd/postgresflex/backup/describe/describe_test.go b/internal/cmd/postgresflex/backup/describe/describe_test.go
index c2aab7317..cd44d829e 100644
--- a/internal/cmd/postgresflex/backup/describe/describe_test.go
+++ b/internal/cmd/postgresflex/backup/describe/describe_test.go
@@ -3,17 +3,19 @@ package describe
import (
"context"
"testing"
+ "time"
- "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "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"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -21,6 +23,7 @@ var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testBackupId = "backupID"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -34,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)
@@ -47,6 +51,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -59,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiGetBackupRequest)) postgresflex.ApiGetBackupRequest {
- request := testClient.GetBackup(testCtx, testProjectId, testInstanceId, testBackupId)
+ request := testClient.GetBackup(testCtx, testProjectId, testRegion, testInstanceId, testBackupId)
for _, mod := range mods {
mod(&request)
}
@@ -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,
},
@@ -235,3 +240,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ backup postgresflex.Backup
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, true},
+ {"standard", args{outputFormat: "", backup: postgresflex.Backup{StartTime: utils.Ptr(time.Now().Format(time.RFC3339))}}, false},
+ {"complete", args{outputFormat: "", backup: postgresflex.Backup{
+ EndTime: utils.Ptr(time.Now().Format(time.RFC3339)),
+ Id: utils.Ptr("id"),
+ Labels: &[]string{"foo", "bar", "baz"},
+ Name: utils.Ptr("name"),
+ Options: &map[string]string{"test1": "test1", "test2": "test2"},
+ Size: utils.Ptr(int64(42)),
+ StartTime: utils.Ptr(time.Now().Format(time.RFC3339)),
+ }}, 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/postgresflex/backup/list/list.go b/internal/cmd/postgresflex/backup/list/list.go
index 0ed9a5155..004159b2b 100644
--- a/internal/cmd/postgresflex/backup/list/list.go
+++ b/internal/cmd/postgresflex/backup/list/list.go
@@ -2,11 +2,10 @@ package list
import (
"context"
- "encoding/json"
"fmt"
- "github.com/goccy/go-yaml"
- "github.com/inhies/go-bytesize"
+ "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,6 +15,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"time"
@@ -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",
@@ -57,20 +57,20 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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.InstanceId)
+ 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{}
@@ -129,41 +129,30 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiListBackupsRequest {
- req := apiClient.ListBackups(ctx, model.ProjectId, *model.InstanceId)
+ req := apiClient.ListBackups(ctx, model.ProjectId, model.Region, *model.InstanceId)
return req
}
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))
- 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 {
backup := backups[i]
- backupStartTime, err := time.Parse(time.RFC3339, *backup.StartTime)
+ backupStartTime, err := time.Parse(time.RFC3339, utils.PtrString(backup.StartTime))
if err != nil {
return fmt.Errorf("parse backup start time: %w", err)
}
backupExpireDate := backupStartTime.AddDate(backupExpireYearOffset, backupExpireMonthOffset, backupExpireDayOffset).Format(time.DateOnly)
- table.AddRow(*backup.Id, *backup.StartTime, backupExpireDate, bytesize.New(float64(*backup.Size)))
+ backupSize := utils.PtrByteSizeDefault(backup.Size, "n/a")
+ table.AddRow(
+ utils.PtrString(backup.Id),
+ utils.PtrString(backup.StartTime),
+ backupExpireDate,
+ backupSize,
+ )
}
err := table.Display(p)
if err != nil {
@@ -171,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 242bc9563..39c9d8f53 100644
--- a/internal/cmd/postgresflex/backup/list/list_test.go
+++ b/internal/cmd/postgresflex/backup/list/list_test.go
@@ -3,30 +3,35 @@ package list
import (
"context"
"testing"
+ "time"
- "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
- "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "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-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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
+var testRegion = "eu01"
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)
@@ -38,6 +43,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: utils.Ptr(testInstanceId),
@@ -50,7 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiListBackupsRequest)) postgresflex.ApiListBackupsRequest {
- request := testClient.ListBackups(testCtx, testProjectId, testInstanceId)
+ request := testClient.ListBackups(testCtx, testProjectId, testRegion, testInstanceId)
for _, mod := range mods {
mod(&request)
}
@@ -60,6 +66,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
@@ -78,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,
},
@@ -135,45 +142,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)
})
}
}
@@ -205,3 +174,49 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ backups []postgresflex.Backup
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, false},
+ {"standard", args{outputFormat: "", backups: []postgresflex.Backup{}}, false},
+ {"complete", args{outputFormat: "", backups: []postgresflex.Backup{
+ {
+ EndTime: utils.Ptr(time.Now().Format(time.RFC3339)),
+ Id: utils.Ptr("id"),
+ Labels: &[]string{"foo", "bar", "baz"},
+ Name: utils.Ptr("name"),
+ Options: &map[string]string{"test1": "test1", "test2": "test2"},
+ Size: utils.Ptr(int64(42)),
+ StartTime: utils.Ptr(time.Now().Format(time.RFC3339)),
+ },
+ {
+ EndTime: utils.Ptr(time.Now().Format(time.RFC3339)),
+ Id: utils.Ptr("id"),
+ Labels: &[]string{"foo", "bar", "baz"},
+ Name: utils.Ptr("name"),
+ Options: &map[string]string{"test1": "test1", "test2": "test2"},
+ Size: utils.Ptr(int64(42)),
+ StartTime: utils.Ptr(time.Now().Format(time.RFC3339)),
+ },
+ },
+ }, 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/postgresflex/backup/update-schedule/update_schedule.go b/internal/cmd/postgresflex/backup/update-schedule/update_schedule.go
index 318425c0c..16bb967fe 100644
--- a/internal/cmd/postgresflex/backup/update-schedule/update_schedule.go
+++ b/internal/cmd/postgresflex/backup/update-schedule/update_schedule.go
@@ -4,6 +4,8 @@ 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"
@@ -28,7 +30,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",
@@ -43,29 +45,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
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.InstanceId)
+ 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 +91,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{}
@@ -105,7 +105,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
}
func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiUpdateBackupScheduleRequest {
- req := apiClient.UpdateBackupSchedule(ctx, model.ProjectId, *model.InstanceId)
+ req := apiClient.UpdateBackupSchedule(ctx, model.ProjectId, model.Region, *model.InstanceId)
req = req.UpdateBackupSchedulePayload(postgresflex.UpdateBackupSchedulePayload{
BackupSchedule: model.BackupSchedule,
})
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 0f28528cb..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"
@@ -13,8 +14,6 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,12 +21,14 @@ var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testSchedule = "0 0 * * *"
+var testRegion = "eu01"
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,6 +40,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: utils.Ptr(testInstanceId),
@@ -61,7 +63,7 @@ func fixturePayload(mods ...func(payload *postgresflex.UpdateBackupSchedulePaylo
}
func fixtureRequest(mods ...func(request *postgresflex.ApiUpdateBackupScheduleRequest)) postgresflex.ApiUpdateBackupScheduleRequest {
- request := testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId)
+ request := testClient.UpdateBackupSchedule(testCtx, testProjectId, testRegion, testInstanceId)
request = request.UpdateBackupSchedulePayload(fixturePayload())
for _, mod := range mods {
mod(&request)
@@ -72,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
@@ -91,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,
},
@@ -141,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)
})
}
}
@@ -200,10 +165,11 @@ func TestBuildRequest(t *testing.T) {
model: &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
},
InstanceId: utils.Ptr(testInstanceId),
},
- expectedRequest: testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId).
+ expectedRequest: testClient.UpdateBackupSchedule(testCtx, testProjectId, testRegion, testInstanceId).
UpdateBackupSchedulePayload(postgresflex.UpdateBackupSchedulePayload{}),
},
}
diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go
index b9d10e78b..d9a784d2a 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.InstanceId)
+ 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 := spinner.New(params.Printer)
s.Start("Cloning instance")
- _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
+ _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for PostgreSQL Flex instance cloning: %w", err)
}
s.Stop()
}
- return outputResult(p, model, instanceLabel, instanceId, resp)
+ return outputResult(params.Printer, model.OutputFormat, model.Async, instanceLabel, instanceId, resp)
},
}
configureFlags(cmd)
@@ -149,30 +147,22 @@ 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
}
type PostgreSQLFlexClient interface {
- CloneInstance(ctx context.Context, projectId, instanceId string) postgresflex.ApiCloneInstanceRequest
- GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*postgresflex.InstanceResponse, error)
- ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*postgresflex.ListStoragesResponse, error)
+ CloneInstance(ctx context.Context, projectId, region, instanceId string) postgresflex.ApiCloneInstanceRequest
+ GetInstanceExecute(ctx context.Context, projectId, region, instanceId string) (*postgresflex.InstanceResponse, error)
+ ListStoragesExecute(ctx context.Context, projectId, region, flavorId string) (*postgresflex.ListStoragesResponse, error)
}
func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFlexClient) (postgresflex.ApiCloneInstanceRequest, error) {
- req := apiClient.CloneInstance(ctx, model.ProjectId, model.InstanceId)
+ req := apiClient.CloneInstance(ctx, model.ProjectId, model.Region, model.InstanceId)
var storages *postgresflex.ListStoragesResponse
if model.StorageClass != nil || model.StorageSize != nil {
- currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.InstanceId)
+ currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.Region, model.InstanceId)
if err != nil {
return req, fmt.Errorf("get PostgreSQL Flex instance: %w", err)
}
@@ -180,7 +170,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl
currentInstanceStorageClass := currentInstance.Item.Storage.Class
currentInstanceStorageSize := currentInstance.Item.Storage.Size
- storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *validationFlavorId)
+ storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, model.Region, *validationFlavorId)
if err != nil {
return req, fmt.Errorf("get PostgreSQL Flex storages: %w", err)
}
@@ -205,30 +195,17 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel, instanceId string, resp *postgresflex.CloneInstanceResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal PostgresFlex instance clone: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, instanceLabel, instanceId string, resp *postgresflex.CloneInstanceResponse) error {
+ if resp == nil {
+ return fmt.Errorf("response not set")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Cloned"
- if model.Async {
+ 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 47906de21..657670aa2 100644
--- a/internal/cmd/postgresflex/instance/clone/clone_test.go
+++ b/internal/cmd/postgresflex/instance/clone/clone_test.go
@@ -6,18 +6,18 @@ import (
"testing"
"time"
- "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-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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -30,18 +30,18 @@ type postgresFlexClientMocked struct {
getInstanceResp *postgresflex.InstanceResponse
}
-func (c *postgresFlexClientMocked) CloneInstance(ctx context.Context, projectId, instanceId string) postgresflex.ApiCloneInstanceRequest {
- return testClient.CloneInstance(ctx, projectId, instanceId)
+func (c *postgresFlexClientMocked) CloneInstance(ctx context.Context, projectId, region, instanceId string) postgresflex.ApiCloneInstanceRequest {
+ return testClient.CloneInstance(ctx, projectId, region, instanceId)
}
-func (c *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*postgresflex.InstanceResponse, error) {
+func (c *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _, _ string) (*postgresflex.InstanceResponse, error) {
if c.getInstanceFails {
return nil, fmt.Errorf("get instance failed")
}
return c.getInstanceResp, nil
}
-func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*postgresflex.ListStoragesResponse, error) {
+func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, _ string) (*postgresflex.ListStoragesResponse, error) {
if c.listStoragesFails {
return nil, fmt.Errorf("list storages failed")
}
@@ -54,6 +54,7 @@ var testRecoveryTimestamp = "2024-03-08T09:28:00+00:00"
var testFlavorId = uuid.NewString()
var testStorageClass = "premium-perf4-stackit"
var testStorageSize = int64(10)
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -67,8 +68,9 @@ func fixtureArgValues(mods ...func(argValues []string)) []string {
func fixtureRequiredFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
- projectIdFlag: testProjectId,
- recoveryTimestampFlag: testRecoveryTimestamp,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ recoveryTimestampFlag: testRecoveryTimestamp,
}
for _, mod := range mods {
mod(flagValues)
@@ -78,10 +80,11 @@ 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,
- recoveryTimestampFlag: testRecoveryTimestamp,
- storageClassFlag: "class",
- storageSizeFlag: "10",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ recoveryTimestampFlag: testRecoveryTimestamp,
+ storageClassFlag: "class",
+ storageSizeFlag: "10",
}
for _, mod := range mods {
mod(flagValues)
@@ -99,6 +102,7 @@ func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -120,6 +124,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -134,7 +139,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiCloneInstanceRequest)) postgresflex.ApiCloneInstanceRequest {
- request := testClient.CloneInstance(testCtx, testProjectId, testInstanceId)
+ request := testClient.CloneInstance(testCtx, testProjectId, testRegion, testInstanceId)
request = request.CloneInstancePayload(fixturePayload())
for _, mod := range mods {
mod(&request)
@@ -231,7 +236,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,
},
@@ -239,7 +244,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,
},
@@ -247,7 +252,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,
},
@@ -299,54 +304,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)
})
}
}
@@ -398,7 +356,7 @@ func TestBuildRequest(t *testing.T) {
Max: utils.Ptr(int64(100)),
},
},
- expectedRequest: testClient.CloneInstance(testCtx, testProjectId, testInstanceId).
+ expectedRequest: testClient.CloneInstance(testCtx, testProjectId, testRegion, testInstanceId).
CloneInstancePayload(postgresflex.CloneInstancePayload{
Class: utils.Ptr("class"),
Timestamp: utils.Ptr(recoveryTimestampString),
@@ -429,7 +387,7 @@ func TestBuildRequest(t *testing.T) {
Max: utils.Ptr(int64(100)),
},
},
- expectedRequest: testClient.CloneInstance(testCtx, testProjectId, testInstanceId).
+ expectedRequest: testClient.CloneInstance(testCtx, testProjectId, testRegion, testInstanceId).
CloneInstancePayload(postgresflex.CloneInstancePayload{
Class: utils.Ptr("class"),
Size: utils.Ptr(int64(10)),
@@ -529,3 +487,35 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ OutputFormat string
+ instanceLabel string
+ instanceId string
+ async bool
+ resp *postgresflex.CloneInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, true},
+ {"standard", args{
+ instanceLabel: "foo",
+ instanceId: "bar",
+ resp: &postgresflex.CloneInstanceResponse{InstanceId: utils.Ptr("id")},
+ }, 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.instanceLabel, tt.args.instanceId, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/postgresflex/instance/create/create.go b/internal/cmd/postgresflex/instance/create/create.go
index ed7cec878..8359ad2f7 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",
@@ -77,34 +77,32 @@ func NewCmd(p *print.Printer) *cobra.Command {
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
if model.Version == nil {
- version, err := postgresflexUtils.GetLatestPostgreSQLVersion(ctx, apiClient, model.ProjectId)
+ version, err := postgresflexUtils.GetLatestPostgreSQLVersion(ctx, apiClient, model.ProjectId, model.Region)
if err != nil {
return fmt.Errorf("get latest PostgreSQL version: %w", err)
}
@@ -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 := spinner.New(params.Printer)
s.Start("Creating instance")
- _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
+ _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for PostgreSQL Flex instance creation: %w", err)
}
s.Stop()
}
- return outputResult(p, model, 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,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 PostgreSQLFlexClient interface {
- CreateInstance(ctx context.Context, projectId string) postgresflex.ApiCreateInstanceRequest
- ListFlavorsExecute(ctx context.Context, projectId string) (*postgresflex.ListFlavorsResponse, error)
- ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*postgresflex.ListStoragesResponse, error)
+ CreateInstance(ctx context.Context, projectId, region string) postgresflex.ApiCreateInstanceRequest
+ ListFlavorsExecute(ctx context.Context, projectId, region string) (*postgresflex.ListFlavorsResponse, error)
+ ListStoragesExecute(ctx context.Context, projectId, region, flavorId string) (*postgresflex.ListStoragesResponse, error)
}
func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFlexClient) (postgresflex.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 PostgreSQL Flex flavors: %w", err)
}
@@ -241,7 +231,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl
flavorId = model.FlavorId
}
- storages, err := apiClient.ListStoragesExecute(ctx, model.ProjectId, *flavorId)
+ storages, err := apiClient.ListStoragesExecute(ctx, model.ProjectId, model.Region, *flavorId)
if err != nil {
return req, fmt.Errorf("get PostgreSQL Flex storages: %w", err)
}
@@ -273,30 +263,16 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel, instanceId string, resp *postgresflex.CreateInstanceResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal PostgresFlex instance: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel, instanceId string, resp *postgresflex.CreateInstanceResponse) error {
+ if resp == nil {
+ return fmt.Errorf("no response passed")
+ }
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Created"
- if model.Async {
+ 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 8b9bb9450..f3fa498c2 100644
--- a/internal/cmd/postgresflex/instance/create/create_test.go
+++ b/internal/cmd/postgresflex/instance/create/create_test.go
@@ -5,22 +5,23 @@ import (
"fmt"
"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-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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &postgresflex.APIClient{}
+var testRegion = "eu01"
type postgresFlexClientMocked struct {
listFlavorsFails bool
@@ -29,18 +30,18 @@ type postgresFlexClientMocked struct {
listStoragesResp *postgresflex.ListStoragesResponse
}
-func (c *postgresFlexClientMocked) CreateInstance(ctx context.Context, projectId string) postgresflex.ApiCreateInstanceRequest {
- return testClient.CreateInstance(ctx, projectId)
+func (c *postgresFlexClientMocked) CreateInstance(ctx context.Context, projectId, region string) postgresflex.ApiCreateInstanceRequest {
+ return testClient.CreateInstance(ctx, projectId, region)
}
-func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*postgresflex.ListStoragesResponse, error) {
+func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, _ string) (*postgresflex.ListStoragesResponse, error) {
if c.listFlavorsFails {
return nil, fmt.Errorf("list storages failed")
}
return c.listStoragesResp, nil
}
-func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*postgresflex.ListFlavorsResponse, error) {
+func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*postgresflex.ListFlavorsResponse, error) {
if c.listFlavorsFails {
return nil, fmt.Errorf("list flavors failed")
}
@@ -52,15 +53,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 * * *",
- flavorIdFlag: testFlavorId,
- storageClassFlag: "premium-perf4-stackit", // 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 * * *",
+ flavorIdFlag: testFlavorId,
+ storageClassFlag: "premium-perf4-stackit", // Non-default
+ storageSizeFlag: "10",
+ versionFlag: "6.0",
+ typeFlag: "Replica",
}
for _, mod := range mods {
mod(flagValues)
@@ -72,6 +74,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 +93,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiCreateInstanceRequest)) postgresflex.ApiCreateInstanceRequest {
- request := testClient.CreateInstance(testCtx, testProjectId)
+ request := testClient.CreateInstance(testCtx, testProjectId, testRegion)
request = request.CreateInstancePayload(fixturePayload())
for _, mod := range mods {
mod(&request)
@@ -123,6 +126,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
@@ -165,21 +169,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 +253,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)
})
}
}
@@ -521,3 +478,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ projectLabel string
+ instanceId string
+ resp *postgresflex.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, true},
+ {"standard", args{
+ outputFormat: "",
+ async: false,
+ projectLabel: "label",
+ instanceId: "4711",
+ resp: &postgresflex.CreateInstanceResponse{Id: utils.Ptr("id")},
+ },
+ 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.instanceId, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/postgresflex/instance/delete/delete.go b/internal/cmd/postgresflex/instance/delete/delete.go
index 97a4ec245..2108d31c3 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.InstanceId)
+ 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,9 +92,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
- _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
+ _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for PostgreSQL Flex instance deletion: %w", err)
}
@@ -112,9 +112,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Forcing deletion of instance")
- _, err = wait.ForceDeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
+ _, err = wait.ForceDeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for PostgreSQL Flex instance force deletion: %w", err)
}
@@ -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,36 +158,28 @@ 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
}
func buildDeleteRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiDeleteInstanceRequest {
- req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId)
+ req := apiClient.DeleteInstance(ctx, model.ProjectId, model.Region, model.InstanceId)
return req
}
func buildForceDeleteRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiForceDeleteInstanceRequest {
- req := apiClient.ForceDeleteInstance(ctx, model.ProjectId, model.InstanceId)
+ req := apiClient.ForceDeleteInstance(ctx, model.ProjectId, model.Region, model.InstanceId)
return req
}
type PostgreSQLFlexClient interface {
- GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*postgresflex.InstanceResponse, error)
- ListVersionsExecute(ctx context.Context, projectId string) (*postgresflex.ListVersionsResponse, error)
- GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*postgresflex.GetUserResponse, error)
+ GetInstanceExecute(ctx context.Context, projectId, region, instanceId string) (*postgresflex.InstanceResponse, error)
+ ListVersionsExecute(ctx context.Context, projectId, region string) (*postgresflex.ListVersionsResponse, error)
+ GetUserExecute(ctx context.Context, projectId, region, instanceId, userId string) (*postgresflex.GetUserResponse, error)
}
func getNextOperations(ctx context.Context, model *inputModel, apiClient PostgreSQLFlexClient) (toDelete, toForceDelete bool, err error) {
- instanceStatus, err := postgresflexUtils.GetInstanceStatus(ctx, apiClient, model.ProjectId, model.InstanceId)
+ instanceStatus, err := postgresflexUtils.GetInstanceStatus(ctx, apiClient, model.ProjectId, model.Region, model.InstanceId)
if err != nil {
return false, false, fmt.Errorf("get PostgreSQL Flex instance status: %w", err)
}
diff --git a/internal/cmd/postgresflex/instance/delete/delete_test.go b/internal/cmd/postgresflex/instance/delete/delete_test.go
index 831180394..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"
@@ -16,32 +16,31 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex/wait"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
+var testRegion = "eu01"
type postgresFlexClientMocked struct {
getInstanceFails bool
getInstanceResp *postgresflex.InstanceResponse
}
-func (c *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*postgresflex.InstanceResponse, error) {
+func (c *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _, _ string) (*postgresflex.InstanceResponse, error) {
if c.getInstanceFails {
return nil, fmt.Errorf("get instance failed")
}
return c.getInstanceResp, nil
}
-func (c *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*postgresflex.ListVersionsResponse, error) {
+func (c *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _, _ string) (*postgresflex.ListVersionsResponse, error) {
// Not used in testing
return nil, nil
}
-func (c *postgresFlexClientMocked) GetUserExecute(_ context.Context, _, _, _ string) (*postgresflex.GetUserResponse, error) {
+func (c *postgresFlexClientMocked) GetUserExecute(_ context.Context, _, _, _, _ string) (*postgresflex.GetUserResponse, error) {
// Not used in testing
return nil, nil
}
@@ -58,7 +57,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)
@@ -70,6 +70,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -81,7 +82,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureDeleteRequest(mods ...func(request *postgresflex.ApiDeleteInstanceRequest)) postgresflex.ApiDeleteInstanceRequest {
- request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId)
+ request := testClient.DeleteInstance(testCtx, testProjectId, testRegion, testInstanceId)
for _, mod := range mods {
mod(&request)
}
@@ -89,7 +90,7 @@ func fixtureDeleteRequest(mods ...func(request *postgresflex.ApiDeleteInstanceRe
}
func fixtureForceDeleteRequest(mods ...func(request *postgresflex.ApiForceDeleteInstanceRequest)) postgresflex.ApiForceDeleteInstanceRequest {
- request := testClient.ForceDeleteInstance(testCtx, testProjectId, testInstanceId)
+ request := testClient.ForceDeleteInstance(testCtx, testProjectId, testRegion, testInstanceId)
for _, mod := range mods {
mod(&request)
}
@@ -133,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,
},
@@ -141,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,
},
@@ -149,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,
},
@@ -169,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 3ca2e291a..a50f3f167 100644
--- a/internal/cmd/postgresflex/instance/describe/describe.go
+++ b/internal/cmd/postgresflex/instance/describe/describe.go
@@ -2,11 +2,10 @@ 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"
@@ -32,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("describe %s", instanceIdArg),
Short: "Shows details of a PostgreSQL Flex instance",
@@ -48,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
}
@@ -65,7 +64,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
@@ -84,75 +83,61 @@ 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 *postgresflex.APIClient) postgresflex.ApiGetInstanceRequest {
- req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId)
+ req := apiClient.GetInstance(ctx, model.ProjectId, model.Region, model.InstanceId)
return req
}
func outputResult(p *print.Printer, outputFormat string, instance *postgresflex.Instance) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal PostgreSQL Flex instance: %w", err)
+ if instance == nil {
+ return fmt.Errorf("no response passed")
+ }
+ return p.OutputResult(outputFormat, instance, func() error {
+ acls := ""
+ if instance.HasAcl() && instance.Acl.HasItems() {
+ acls = utils.JoinStringPtr(instance.Acl.Items, ",")
}
- p.Outputln(string(details))
- return nil
- default:
- aclsArray := *instance.Acl.Items
- acls := strings.Join(aclsArray, ",")
-
- instanceType, err := postgresflexUtils.GetInstanceType(*instance.Replicas)
+ instanceType, err := postgresflexUtils.GetInstanceType(utils.PtrValue(instance.Replicas))
if err != nil {
// Should never happen
instanceType = ""
}
table := tables.NewTable()
- table.AddRow("ID", *instance.Id)
+ table.AddRow("ID", utils.PtrString(instance.Id))
table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("STATUS", cases.Title(language.English).String(*instance.Status))
+ table.AddRow("STATUS", cases.Title(language.English).String(utils.PtrString(instance.Status)))
table.AddSeparator()
- table.AddRow("STORAGE SIZE (GB)", *instance.Storage.Size)
+ if instance.Storage != nil {
+ table.AddRow("STORAGE SIZE (GB)", utils.PtrString(instance.Storage.Size))
+ }
table.AddSeparator()
- table.AddRow("VERSION", *instance.Version)
+ table.AddRow("VERSION", utils.PtrString(instance.Version))
table.AddSeparator()
table.AddRow("ACL", acls)
table.AddSeparator()
- table.AddRow("FLAVOR DESCRIPTION", *instance.Flavor.Description)
+ if instance.Flavor != nil {
+ table.AddRow("FLAVOR DESCRIPTION", utils.PtrString(instance.Flavor.Description))
+ }
table.AddSeparator()
table.AddRow("TYPE", instanceType)
table.AddSeparator()
- table.AddRow("REPLICAS", *instance.Replicas)
- table.AddSeparator()
- table.AddRow("CPU", *instance.Flavor.Cpu)
+ table.AddRow("REPLICAS", utils.PtrString(instance.Replicas))
table.AddSeparator()
- table.AddRow("RAM (GB)", *instance.Flavor.Memory)
+ if instance.Flavor != nil {
+ table.AddRow("CPU", utils.PtrString(instance.Flavor.Cpu))
+ table.AddSeparator()
+ table.AddRow("RAM (GB)", utils.PtrString(instance.Flavor.Memory))
+ }
table.AddSeparator()
- table.AddRow("BACKUP SCHEDULE (UTC)", *instance.BackupSchedule)
+ table.AddRow("BACKUP SCHEDULE (UTC)", utils.PtrString(instance.BackupSchedule))
table.AddSeparator()
err = table.Display(p)
if err != nil {
@@ -160,5 +145,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 b71920a5b..80ce6c262 100644
--- a/internal/cmd/postgresflex/instance/describe/describe_test.go
+++ b/internal/cmd/postgresflex/instance/describe/describe_test.go
@@ -4,23 +4,24 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -34,7 +35,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 +48,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -57,7 +60,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiGetInstanceRequest)) postgresflex.ApiGetInstanceRequest {
- request := testClient.GetInstance(testCtx, testProjectId, testInstanceId)
+ request := testClient.GetInstance(testCtx, testProjectId, testRegion, testInstanceId)
for _, mod := range mods {
mod(&request)
}
@@ -101,7 +104,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 +112,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 +120,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 +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)
})
}
}
@@ -216,3 +172,56 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *postgresflex.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, true},
+ {"standard", args{
+ outputFormat: "",
+ instance: &postgresflex.Instance{},
+ }, false},
+ {"complete", args{
+ outputFormat: "",
+ instance: &postgresflex.Instance{
+ Acl: &postgresflex.ACL{
+ Items: &[]string{},
+ },
+ BackupSchedule: new(string),
+ Flavor: &postgresflex.Flavor{
+ Cpu: new(int64),
+ Description: new(string),
+ Id: new(string),
+ Memory: new(int64),
+ },
+ Id: new(string),
+ Name: new(string),
+ Options: &map[string]string{},
+ Replicas: new(int64),
+ Status: new(string),
+ Storage: &postgresflex.Storage{
+ Class: new(string),
+ Size: new(int64),
+ },
+ Version: new(string),
+ },
+ }, 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.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
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 3759061ea..2b20dc3e2 100644
--- a/internal/cmd/postgresflex/instance/list/list.go
+++ b/internal/cmd/postgresflex/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "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"
@@ -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 PostgreSQL Flex instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 {
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 +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)
},
}
@@ -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,48 +114,27 @@ 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 *postgresflex.APIClient) postgresflex.ApiListInstancesRequest {
- req := apiClient.ListInstances(ctx, model.ProjectId)
+ req := apiClient.ListInstances(ctx, model.ProjectId, model.Region)
return req
}
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))
- 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")
for i := range instances {
instance := instances[i]
- table.AddRow(*instance.Id, *instance.Name, caser.String(*instance.Status))
+ table.AddRow(
+ utils.PtrString(instance.Id),
+ utils.PtrString(instance.Name),
+ caser.String(utils.PtrString(instance.Status)),
+ )
}
err := table.Display(p)
if err != nil {
@@ -163,5 +142,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 0060c7645..dfde2729a 100644
--- a/internal/cmd/postgresflex/instance/list/list_test.go
+++ b/internal/cmd/postgresflex/instance/list/list_test.go
@@ -4,29 +4,30 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &postgresflex.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,
- limitFlag: "10",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ limitFlag: "10",
}
for _, mod := range mods {
mod(flagValues)
@@ -38,6 +39,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 +51,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiListInstancesRequest)) postgresflex.ApiListInstancesRequest {
- request := testClient.ListInstances(testCtx, testProjectId)
+ request := testClient.ListInstances(testCtx, testProjectId, testRegion)
for _, mod := range mods {
mod(&request)
}
@@ -59,6 +61,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
@@ -77,21 +80,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 +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)
})
}
}
@@ -186,3 +148,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instances []postgresflex.InstanceListInstance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, false},
+ {"standard", args{"", []postgresflex.InstanceListInstance{}}, false},
+ {"complete", args{"", []postgresflex.InstanceListInstance{
+ {
+ Id: new(string),
+ Name: new(string),
+ Status: new(string),
+ },
+ {
+ Id: new(string),
+ Name: new(string),
+ Status: new(string),
+ },
+ {
+ Id: new(string),
+ Name: new(string),
+ Status: new(string),
+ },
+ }}, 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.instances); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/postgresflex/instance/update/update.go b/internal/cmd/postgresflex/instance/update/update.go
index 975c7c860..8623a78f4 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.InstanceId)
+ 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 := spinner.New(params.Printer)
s.Start("Updating instance")
- _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
+ _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, instanceId).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for PostgreSQL Flex instance update: %w", err)
}
s.Stop()
}
- return outputResult(p, model, 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 PostgreSQLFlexClient interface {
- PartialUpdateInstance(ctx context.Context, projectId, instanceId string) postgresflex.ApiPartialUpdateInstanceRequest
- GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*postgresflex.InstanceResponse, error)
- ListFlavorsExecute(ctx context.Context, projectId string) (*postgresflex.ListFlavorsResponse, error)
- ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*postgresflex.ListStoragesResponse, error)
+ PartialUpdateInstance(ctx context.Context, projectId, region, instanceId string) postgresflex.ApiPartialUpdateInstanceRequest
+ GetInstanceExecute(ctx context.Context, projectId, region, instanceId string) (*postgresflex.InstanceResponse, error)
+ ListFlavorsExecute(ctx context.Context, projectId, region string) (*postgresflex.ListFlavorsResponse, error)
+ ListStoragesExecute(ctx context.Context, projectId, region, flavorId string) (*postgresflex.ListStoragesResponse, error)
}
func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFlexClient) (postgresflex.ApiPartialUpdateInstanceRequest, error) {
- req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.InstanceId)
+ req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.Region, model.InstanceId)
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 PostgreSQL Flex flavors: %w", err)
}
@@ -220,7 +210,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl
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.Region, model.InstanceId)
if err != nil {
return req, fmt.Errorf("get PostgreSQL Flex instance: %w", err)
}
@@ -251,13 +241,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl
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.Region, model.InstanceId)
if err != nil {
return req, fmt.Errorf("get PostgreSQL Flex instance: %w", err)
}
validationFlavorId = currentInstance.Item.Flavor.Id
}
- storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *validationFlavorId)
+ storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, model.Region, *validationFlavorId)
if err != nil {
return req, fmt.Errorf("get PostgreSQL Flex storages: %w", err)
}
@@ -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,
}
@@ -307,30 +297,17 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *postgresflex.PartialUpdateInstanceResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal PostgresFlex instance: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, instanceLabel string, resp *postgresflex.PartialUpdateInstanceResponse) error {
+ if resp == nil {
+ return fmt.Errorf("no response passed")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, resp, func() error {
operationState := "Updated"
- if model.Async {
+ 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 5ac0a472f..17cfb46c8 100644
--- a/internal/cmd/postgresflex/instance/update/update_test.go
+++ b/internal/cmd/postgresflex/instance/update/update_test.go
@@ -5,22 +5,22 @@ import (
"fmt"
"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-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"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &postgresflex.APIClient{}
+var testRegion = "eu01"
type postgresFlexClientMocked struct {
listFlavorsFails bool
@@ -31,25 +31,25 @@ type postgresFlexClientMocked struct {
getInstanceResp *postgresflex.InstanceResponse
}
-func (c *postgresFlexClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId string) postgresflex.ApiPartialUpdateInstanceRequest {
- return testClient.PartialUpdateInstance(ctx, projectId, instanceId)
+func (c *postgresFlexClientMocked) PartialUpdateInstance(ctx context.Context, projectId, region, instanceId string) postgresflex.ApiPartialUpdateInstanceRequest {
+ return testClient.PartialUpdateInstance(ctx, projectId, region, instanceId)
}
-func (c *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*postgresflex.InstanceResponse, error) {
+func (c *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _, _ string) (*postgresflex.InstanceResponse, error) {
if c.getInstanceFails {
return nil, fmt.Errorf("get instance failed")
}
return c.getInstanceResp, nil
}
-func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*postgresflex.ListStoragesResponse, error) {
+func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, _ string) (*postgresflex.ListStoragesResponse, error) {
if c.listFlavorsFails {
return nil, fmt.Errorf("list storages failed")
}
return c.listStoragesResp, nil
}
-func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*postgresflex.ListFlavorsResponse, error) {
+func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*postgresflex.ListFlavorsResponse, error) {
if c.listFlavorsFails {
return nil, fmt.Errorf("list flavors failed")
}
@@ -72,7 +72,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 +83,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 +104,7 @@ func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -116,6 +119,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -135,7 +139,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiPartialUpdateInstanceRequest)) postgresflex.ApiPartialUpdateInstanceRequest {
- request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId)
+ request := testClient.PartialUpdateInstance(testCtx, testProjectId, testRegion, testInstanceId)
request = request.PartialUpdateInstancePayload(postgresflex.PartialUpdateInstancePayload{})
for _, mod := range mods {
mod(&request)
@@ -203,7 +207,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 +215,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 +223,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 +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)
@@ -375,7 +379,7 @@ func TestBuildRequest(t *testing.T) {
},
},
},
- expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId).
+ expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testRegion, testInstanceId).
PartialUpdateInstancePayload(postgresflex.PartialUpdateInstancePayload{
FlavorId: utils.Ptr(testFlavorId),
}),
@@ -396,7 +400,7 @@ func TestBuildRequest(t *testing.T) {
},
},
},
- expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId).
+ expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testRegion, testInstanceId).
PartialUpdateInstancePayload(postgresflex.PartialUpdateInstancePayload{
FlavorId: utils.Ptr(testFlavorId),
}),
@@ -421,9 +425,9 @@ func TestBuildRequest(t *testing.T) {
Max: utils.Ptr(int64(100)),
},
},
- expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId).
+ expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testRegion, testInstanceId).
PartialUpdateInstancePayload(postgresflex.PartialUpdateInstancePayload{
- Storage: &postgresflex.Storage{
+ Storage: &postgresflex.StorageUpdate{
Class: utils.Ptr("class"),
},
}),
@@ -449,9 +453,9 @@ func TestBuildRequest(t *testing.T) {
Max: utils.Ptr(int64(100)),
},
},
- expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId).
+ 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)),
},
@@ -590,3 +594,41 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ resp *postgresflex.PartialUpdateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty model", args{}, true},
+ {"empty response", args{outputFormat: ""}, true},
+ {"standard", args{
+ outputFormat: "",
+ instanceLabel: "test",
+ resp: &postgresflex.PartialUpdateInstanceResponse{},
+ }, false},
+ {"complet", args{
+ outputFormat: "",
+ instanceLabel: "test",
+ resp: &postgresflex.PartialUpdateInstanceResponse{
+ Item: &postgresflex.Instance{},
+ },
+ }, 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, true, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/postgresflex/options/options.go b/internal/cmd/postgresflex/options/options.go
index ba94aea08..ad57495d3 100644
--- a/internal/cmd/postgresflex/options/options.go
+++ b/internal/cmd/postgresflex/options/options.go
@@ -2,19 +2,20 @@ 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-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/postgresflex/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
@@ -45,7 +46,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",
@@ -64,19 +65,19 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +96,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,22 +127,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 postgresFlexOptionsClient interface {
- ListFlavorsExecute(ctx context.Context, projectId string) (*postgresflex.ListFlavorsResponse, error)
- ListVersionsExecute(ctx context.Context, projectId string) (*postgresflex.ListVersionsResponse, error)
- ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*postgresflex.ListStoragesResponse, error)
+ ListFlavorsExecute(ctx context.Context, projectId, region string) (*postgresflex.ListFlavorsResponse, error)
+ ListVersionsExecute(ctx context.Context, projectId, region string) (*postgresflex.ListVersionsResponse, error)
+ ListStoragesExecute(ctx context.Context, projectId, region, flavorId string) (*postgresflex.ListStoragesResponse, error)
}
func buildAndExecuteRequest(ctx context.Context, p *print.Printer, model *inputModel, apiClient postgresFlexOptionsClient) error {
@@ -148,103 +144,83 @@ 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 PostgreSQL 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 PostgreSQL Flex versions: %w", err)
}
}
if model.Storages {
- storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *model.FlavorId)
+ storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, model.Region, *model.FlavorId)
if err != nil {
return fmt.Errorf("get PostgreSQL Flex storages: %w", err)
}
}
- return outputResult(p, model, flavors, versions, storages)
+ return outputResult(p, *model, flavors, versions, storages)
}
-func outputResult(p *print.Printer, model *inputModel, flavors *postgresflex.ListFlavorsResponse, versions *postgresflex.ListVersionsResponse, storages *postgresflex.ListStoragesResponse) error {
+func outputResult(p *print.Printer, model inputModel, flavors *postgresflex.ListFlavorsResponse, versions *postgresflex.ListVersionsResponse, storages *postgresflex.ListStoragesResponse) error {
options := &options{}
if flavors != nil {
options.Flavors = flavors.Flavors
}
+ if model.GlobalFlagModel == nil {
+ return fmt.Errorf("no global model defined")
+ }
if versions != nil {
options.Versions = versions.Versions
}
if storages != nil && model.FlavorId != nil {
options.Storages = &flavorStorages{
- FlavorId: *model.FlavorId,
+ FlavorId: utils.PtrString(model.FlavorId),
Storages: storages,
}
}
- 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))
+ 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 := ""
- if model.Flavors {
- content += renderFlavors(*options.Flavors)
- }
- if model.Versions {
- content += renderVersions(*options.Versions)
- }
- if model.Storages {
- content += renderStorages(options.Storages.Storages)
- }
-
- err := p.PagerDisplay(content)
- if err != nil {
- return fmt.Errorf("display output: %w", err)
- }
-
- return nil
+ })
}
-func renderFlavors(flavors []postgresflex.Flavor) string {
- if len(flavors) == 0 {
- return ""
- }
-
+func buildFlavorsTable(flavors []postgresflex.Flavor) tables.Table {
table := tables.NewTable()
table.SetTitle("Flavors")
table.SetHeader("ID", "CPU", "MEMORY", "DESCRIPTION")
for i := range flavors {
f := flavors[i]
- table.AddRow(*f.Id, *f.Cpu, *f.Memory, *f.Description)
- }
- return table.Render()
+ table.AddRow(
+ utils.PtrString(f.Id),
+ utils.PtrString(f.Cpu),
+ utils.PtrString(f.Memory),
+ utils.PtrString(f.Description),
+ )
+ }
+ return table
}
-func renderVersions(versions []string) string {
- if len(versions) == 0 {
- return ""
- }
-
+func buildVersionsTable(versions []string) tables.Table {
table := tables.NewTable()
table.SetTitle("Versions")
table.SetHeader("VERSION")
@@ -252,22 +228,22 @@ func renderVersions(versions []string) string {
v := versions[i]
table.AddRow(v)
}
- return table.Render()
+ return table
}
-func renderStorages(resp *postgresflex.ListStoragesResponse) string {
- if resp.StorageClasses == nil || len(*resp.StorageClasses) == 0 {
- return ""
- }
- storageClasses := *resp.StorageClasses
-
+func buildStoragesTable(storagesResp postgresflex.ListStoragesResponse) tables.Table {
+ storages := *storagesResp.StorageClasses
table := tables.NewTable()
table.SetTitle("Storages")
table.SetHeader("MINIMUM", "MAXIMUM", "STORAGE CLASS")
- for i := range storageClasses {
- sc := storageClasses[i]
- table.AddRow(*resp.StorageRange.Min, *resp.StorageRange.Max, sc)
+ for i := range storages {
+ sc := storages[i]
+ table.AddRow(
+ utils.PtrString(storagesResp.StorageRange.Min),
+ utils.PtrString(storagesResp.StorageRange.Max),
+ sc,
+ )
}
table.EnableAutoMergeOnColumns(1, 2, 3)
- return table.Render()
+ return table
}
diff --git a/internal/cmd/postgresflex/options/options_test.go b/internal/cmd/postgresflex/options/options_test.go
index 1a4866a3b..bd47b14ba 100644
--- a/internal/cmd/postgresflex/options/options_test.go
+++ b/internal/cmd/postgresflex/options/options_test.go
@@ -5,17 +5,20 @@ import (
"fmt"
"testing"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
+ "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/testutils"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
-
- "github.com/google/go-cmp/cmp"
"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
@@ -27,7 +30,7 @@ type postgresFlexClientMocked struct {
listStoragesCalled bool
}
-func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*postgresflex.ListFlavorsResponse, error) {
+func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _, _ string) (*postgresflex.ListFlavorsResponse, error) {
c.listFlavorsCalled = true
if c.listFlavorsFails {
return nil, fmt.Errorf("list flavors failed")
@@ -37,7 +40,7 @@ func (c *postgresFlexClientMocked) ListFlavorsExecute(_ context.Context, _ strin
}), nil
}
-func (c *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*postgresflex.ListVersionsResponse, error) {
+func (c *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _, _ string) (*postgresflex.ListVersionsResponse, error) {
c.listVersionsCalled = true
if c.listVersionsFails {
return nil, fmt.Errorf("list versions failed")
@@ -47,7 +50,7 @@ func (c *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _ stri
}), nil
}
-func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*postgresflex.ListStoragesResponse, error) {
+func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _, _ string) (*postgresflex.ListStoragesResponse, error) {
c.listStoragesCalled = true
if c.listStoragesFails {
return nil, fmt.Errorf("list storages failed")
@@ -63,10 +66,11 @@ func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ s
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)
@@ -76,10 +80,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)
@@ -89,11 +96,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)
@@ -104,6 +114,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 +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)
- err := globalflags.Configure(cmd.Flags())
- if err != nil {
- t.Fatalf("configure global flags: %v", err)
- }
-
- for flag, value := range tt.flagValues {
- err := 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 +263,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,
@@ -322,3 +294,51 @@ func TestBuildAndExecuteRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ model inputModel
+ flavors *postgresflex.ListFlavorsResponse
+ versions *postgresflex.ListVersionsResponse
+ storages *postgresflex.ListStoragesResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{model: inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}}, false},
+ {"standard", args{
+ model: inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}},
+ flavors: &postgresflex.ListFlavorsResponse{},
+ versions: &postgresflex.ListVersionsResponse{},
+ storages: &postgresflex.ListStoragesResponse{},
+ }, false},
+ {
+ "complete",
+ args{
+ model: inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}, Flavors: false, Versions: false, Storages: false, FlavorId: new(string)},
+ flavors: &postgresflex.ListFlavorsResponse{
+ Flavors: &[]postgresflex.Flavor{},
+ },
+ versions: &postgresflex.ListVersionsResponse{
+ Versions: &[]string{},
+ },
+ storages: &postgresflex.ListStoragesResponse{
+ StorageClasses: &[]string{},
+ StorageRange: &postgresflex.StorageRange{},
+ },
+ },
+ 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.flavors, tt.args.versions, tt.args.storages); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 6a0d1f103..fc53b1f07 100644
--- a/internal/cmd/postgresflex/user/create/create.go
+++ b/internal/cmd/postgresflex/user/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/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"
@@ -14,8 +15,7 @@ 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/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
@@ -37,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: "create",
Short: "Creates a PostgreSQL Flex user",
@@ -58,29 +58,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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.InstanceId)
+ 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 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("create PostgreSQL Flex user: %w", err)
}
- return outputResult(p, model, instanceLabel, resp)
+ return outputResult(params.Printer, model.OutputFormat, instanceLabel, resp)
},
}
@@ -109,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{}
@@ -122,20 +120,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 *postgresflex.APIClient) postgresflex.ApiCreateUserRequest {
- req := apiClient.CreateUser(ctx, model.ProjectId, model.InstanceId)
+ req := apiClient.CreateUser(ctx, model.ProjectId, model.Region, model.InstanceId)
req = req.CreateUserPayload(postgresflex.CreateUserPayload{
Username: model.Username,
Roles: model.Roles,
@@ -143,34 +133,22 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresfle
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *postgresflex.CreateUserResponse) error {
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- if err != nil {
- return fmt.Errorf("marshal PostgresFlex user: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, instanceLabel string, resp *postgresflex.CreateUserResponse) error {
+ if resp == nil {
+ return fmt.Errorf("no response passed")
+ }
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal PostgresFlex user: %w", err)
+ 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))
+ p.Outputf("Password: %s\n", utils.PtrString(user.Password))
+ p.Outputf("Roles: %v\n", utils.PtrString(user.Roles))
+ p.Outputf("Host: %s\n", utils.PtrString(user.Host))
+ p.Outputf("Port: %s\n", utils.PtrString(user.Port))
+ p.Outputf("URI: %s\n", utils.PtrString(user.Uri))
}
- p.Outputln(string(details))
return nil
- default:
- user := resp.Item
- p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, *user.Id)
- p.Outputf("Username: %s\n", *user.Username)
- p.Outputf("Password: %s\n", *user.Password)
- p.Outputf("Roles: %v\n", *user.Roles)
- p.Outputf("Host: %s\n", *user.Host)
- p.Outputf("Port: %d\n", *user.Port)
- p.Outputf("URI: %s\n", *user.Uri)
-
- return nil
- }
+ })
}
diff --git a/internal/cmd/postgresflex/user/create/create_test.go b/internal/cmd/postgresflex/user/create/create_test.go
index d90533633..2ab611d42 100644
--- a/internal/cmd/postgresflex/user/create/create_test.go
+++ b/internal/cmd/postgresflex/user/create/create_test.go
@@ -4,32 +4,33 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
+var testRegion = "eu01"
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
- projectIdFlag: testProjectId,
- instanceIdFlag: testInstanceId,
- usernameFlag: "johndoe",
- roleFlag: "login",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
+ usernameFlag: "johndoe",
+ roleFlag: "login",
}
for _, mod := range mods {
mod(flagValues)
@@ -41,6 +42,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -54,7 +56,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiCreateUserRequest)) postgresflex.ApiCreateUserRequest {
- request := testClient.CreateUser(testCtx, testProjectId, testInstanceId)
+ request := testClient.CreateUser(testCtx, testProjectId, testRegion, testInstanceId)
request = request.CreateUserPayload(postgresflex.CreateUserPayload{
Username: utils.Ptr("johndoe"),
Roles: utils.Ptr([]string{"login"}),
@@ -69,6 +71,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
@@ -88,21 +91,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,48 +151,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)
})
}
}
@@ -230,3 +192,41 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ resp *postgresflex.CreateUserResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, true},
+ {"standard", args{resp: &postgresflex.CreateUserResponse{}}, false},
+ {"complete", args{resp: &postgresflex.CreateUserResponse{
+ Item: &postgresflex.User{
+ Database: new(string),
+ Host: new(string),
+ Id: new(string),
+ Password: new(string),
+ Port: new(int64),
+ Roles: &[]string{},
+ Uri: new(string),
+ Username: new(string),
+ },
+ }}, 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.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/postgresflex/user/delete/delete.go b/internal/cmd/postgresflex/user/delete/delete.go
index 0ca1f05ac..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.InstanceId)
+ 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.InstanceId, model.UserId)
+ 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,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 *postgresflex.APIClient) postgresflex.ApiDeleteUserRequest {
- req := apiClient.DeleteUser(ctx, model.ProjectId, model.InstanceId, model.UserId)
+ req := apiClient.DeleteUser(ctx, model.ProjectId, model.Region, model.InstanceId, model.UserId)
return req
}
diff --git a/internal/cmd/postgresflex/user/delete/delete_test.go b/internal/cmd/postgresflex/user/delete/delete_test.go
index f4c38fc32..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"
@@ -13,8 +13,6 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,6 +20,7 @@ var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testUserId = "12345"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -35,8 +34,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 +48,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 fixtureRequest(mods ...func(request *postgresflex.ApiDeleteUserRequest)) postgresflex.ApiDeleteUserRequest {
- request := testClient.DeleteUser(testCtx, testProjectId, testInstanceId, testUserId)
+ request := testClient.DeleteUser(testCtx, testProjectId, testRegion, testInstanceId, testUserId)
for _, mod := range mods {
mod(&request)
}
@@ -104,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,
},
@@ -112,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,
},
@@ -120,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,
},
@@ -158,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 5855ca07d..01ab5fce1 100644
--- a/internal/cmd/postgresflex/user/describe/describe.go
+++ b/internal/cmd/postgresflex/user/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/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"
@@ -14,8 +15,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
@@ -32,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("describe %s", userIdArg),
Short: "Shows details of a PostgreSQL Flex user",
@@ -52,13 +52,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 +70,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,52 +99,27 @@ 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 *postgresflex.APIClient) postgresflex.ApiGetUserRequest {
- req := apiClient.GetUser(ctx, model.ProjectId, model.InstanceId, model.UserId)
+ req := apiClient.GetUser(ctx, model.ProjectId, model.Region, model.InstanceId, model.UserId)
return req
}
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))
- 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", *user.Id)
+ table.AddRow("ID", utils.PtrString(user.Id))
table.AddSeparator()
- table.AddRow("USERNAME", *user.Username)
+ table.AddRow("USERNAME", utils.PtrString(user.Username))
table.AddSeparator()
- table.AddRow("ROLES", *user.Roles)
+ table.AddRow("ROLES", utils.PtrString(user.Roles))
table.AddSeparator()
- table.AddRow("HOST", *user.Host)
+ table.AddRow("HOST", utils.PtrString(user.Host))
table.AddSeparator()
- table.AddRow("PORT", *user.Port)
+ table.AddRow("PORT", utils.PtrString(user.Port))
err := table.Display(p)
if err != nil {
@@ -152,5 +127,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 967c5c640..92b83cc99 100644
--- a/internal/cmd/postgresflex/user/describe/describe_test.go
+++ b/internal/cmd/postgresflex/user/describe/describe_test.go
@@ -4,17 +4,17 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,6 +22,7 @@ var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testUserId = "12345"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -35,8 +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,
- instanceIdFlag: testInstanceId,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
}
for _, mod := range mods {
mod(flagValues)
@@ -48,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -60,7 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiGetUserRequest)) postgresflex.ApiGetUserRequest {
- request := testClient.GetUser(testCtx, testProjectId, testInstanceId, testUserId)
+ request := testClient.GetUser(testCtx, testProjectId, testRegion, testInstanceId, testUserId)
for _, mod := range mods {
mod(&request)
}
@@ -104,7 +107,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 +115,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 +123,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 +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)
- err := globalflags.Configure(cmd.Flags())
- if err != nil {
- t.Fatalf("configure global flags: %v", err)
- }
-
- for flag, value := range tt.flagValues {
- err := 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)
})
}
}
@@ -237,3 +193,35 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ user postgresflex.UserResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"basic", args{}, false},
+ {"standard", args{user: postgresflex.UserResponse{}}, false},
+ {"complete", args{user: postgresflex.UserResponse{
+ Host: new(string),
+ Id: new(string),
+ Port: new(int64),
+ Roles: &[]string{},
+ Username: new(string),
+ }}, 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.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/postgresflex/user/list/list.go b/internal/cmd/postgresflex/user/list/list.go
index 62e354334..a8dc23773 100644
--- a/internal/cmd/postgresflex/user/list/list.go
+++ b/internal/cmd/postgresflex/user/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ 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/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
@@ -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 PostgreSQL Flex users of an instance",
@@ -51,13 +51,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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,12 +69,12 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("get PostgreSQL Flex users: %w", err)
}
if resp.Items == nil || len(*resp.Items) == 0 {
- instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId)
+ 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 +84,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 +100,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,47 +120,25 @@ 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 *postgresflex.APIClient) postgresflex.ApiListUsersRequest {
- req := apiClient.ListUsers(ctx, model.ProjectId, *model.InstanceId)
+ req := apiClient.ListUsers(ctx, model.ProjectId, model.Region, *model.InstanceId)
return req
}
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))
- 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 {
user := users[i]
- table.AddRow(*user.Id, *user.Username)
+ table.AddRow(
+ utils.PtrString(user.Id),
+ utils.PtrString(user.Username),
+ )
}
err := table.Display(p)
if err != nil {
@@ -168,5 +146,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 671b7e383..2695296c7 100644
--- a/internal/cmd/postgresflex/user/list/list_test.go
+++ b/internal/cmd/postgresflex/user/list/list_test.go
@@ -4,31 +4,32 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
+var testRegion = "eu01"
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 +41,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 +54,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiListUsersRequest)) postgresflex.ApiListUsersRequest {
- request := testClient.ListUsers(testCtx, testProjectId, testInstanceId)
+ request := testClient.ListUsers(testCtx, testProjectId, testRegion, testInstanceId)
for _, mod := range mods {
mod(&request)
}
@@ -62,6 +64,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
@@ -80,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,
},
@@ -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)
})
}
}
@@ -203,3 +165,32 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ users []postgresflex.ListUsersResponseItem
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, false},
+ {"standard", args{users: []postgresflex.ListUsersResponseItem{{}}}, false},
+ {"complete", args{users: []postgresflex.ListUsersResponseItem{{
+ Id: new(string),
+ Username: new(string),
+ }}}, 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.users); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/postgresflex/user/reset-password/reset_password.go b/internal/cmd/postgresflex/user/reset-password/reset_password.go
index 77c881189..c899b5659 100644
--- a/internal/cmd/postgresflex/user/reset-password/reset_password.go
+++ b/internal/cmd/postgresflex/user/reset-password/reset_password.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -14,8 +15,7 @@ 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/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
@@ -32,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("reset-password %s", userIdArg),
Short: "Resets the password of a PostgreSQL Flex user",
@@ -48,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 := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId)
+ 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.InstanceId, model.UserId)
+ 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 +84,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("reset PostgreSQL Flex user password: %w", err)
}
- return outputResult(p, model, userLabel, instanceLabel, user)
+ return outputResult(params.Printer, model.OutputFormat, userLabel, instanceLabel, user)
},
}
@@ -115,46 +113,26 @@ 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 *postgresflex.APIClient) postgresflex.ApiResetUserRequest {
- req := apiClient.ResetUser(ctx, model.ProjectId, model.InstanceId, model.UserId)
+ req := apiClient.ResetUser(ctx, model.ProjectId, model.Region, model.InstanceId, model.UserId)
return req
}
-func outputResult(p *print.Printer, model *inputModel, userLabel, instanceLabel string, user *postgresflex.ResetUserResponse) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal PostgresFlex user: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
+func outputResult(p *print.Printer, outputFormat, userLabel, instanceLabel string, user *postgresflex.ResetUserResponse) error {
+ if user == nil {
+ return fmt.Errorf("no response passed")
+ }
+ 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", *user.Item.Username)
- p.Outputf("New password: %s\n", *user.Item.Password)
- p.Outputf("New URI: %s\n", *user.Item.Uri)
+ if item := user.Item; item != nil {
+ p.Outputf("Username: %s\n", utils.PtrString(item.Username))
+ p.Outputf("New password: %s\n", utils.PtrString(item.Password))
+ 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 f147797a0..920c78b93 100644
--- a/internal/cmd/postgresflex/user/reset-password/reset_password_test.go
+++ b/internal/cmd/postgresflex/user/reset-password/reset_password_test.go
@@ -4,17 +4,17 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -22,6 +22,7 @@ var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testUserId = "12345"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -35,8 +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,
- instanceIdFlag: testInstanceId,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
}
for _, mod := range mods {
mod(flagValues)
@@ -48,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -60,7 +63,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiResetUserRequest)) postgresflex.ApiResetUserRequest {
- request := testClient.ResetUser(testCtx, testProjectId, testInstanceId, testUserId)
+ request := testClient.ResetUser(testCtx, testProjectId, testRegion, testInstanceId, testUserId)
for _, mod := range mods {
mod(&request)
}
@@ -104,7 +107,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 +115,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 +123,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 +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)
- err := globalflags.Configure(cmd.Flags())
- if err != nil {
- t.Fatalf("configure global flags: %v", err)
- }
-
- for flag, value := range tt.flagValues {
- err := 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)
})
}
}
@@ -237,3 +193,36 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ userLabel string
+ instanceLabel string
+ user *postgresflex.ResetUserResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, true},
+ {"standard", args{user: &postgresflex.ResetUserResponse{}}, false},
+ {"complete", args{
+ userLabel: "userLabel",
+ instanceLabel: "instanceLabel",
+ user: &postgresflex.ResetUserResponse{
+ Item: &postgresflex.User{},
+ }}, 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.userLabel, tt.args.instanceLabel, tt.args.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/postgresflex/user/update/update.go b/internal/cmd/postgresflex/user/update/update.go
index 7edab64e6..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.InstanceId)
+ 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.InstanceId, model.UserId)
+ 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,20 +122,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 *postgresflex.APIClient) postgresflex.ApiPartialUpdateUserRequest {
- req := apiClient.PartialUpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId)
+ req := apiClient.PartialUpdateUser(ctx, model.ProjectId, model.Region, model.InstanceId, model.UserId)
req = req.PartialUpdateUserPayload(postgresflex.PartialUpdateUserPayload{
Roles: model.Roles,
})
diff --git a/internal/cmd/postgresflex/user/update/update_test.go b/internal/cmd/postgresflex/user/update/update_test.go
index b6cc8e1c1..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"
@@ -14,8 +14,6 @@ import (
"github.com/stackitcloud/stackit-sdk-go/services/postgresflex"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
@@ -23,6 +21,7 @@ var testClient = &postgresflex.APIClient{}
var testProjectId = uuid.NewString()
var testInstanceId = uuid.NewString()
var testUserId = "12345"
+var testRegion = "eu01"
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
@@ -36,9 +35,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,
- roleFlag: "login",
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ instanceIdFlag: testInstanceId,
+ roleFlag: "login",
}
for _, mod := range mods {
mod(flagValues)
@@ -50,6 +50,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
InstanceId: testInstanceId,
@@ -63,7 +64,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
}
func fixtureRequest(mods ...func(request *postgresflex.ApiPartialUpdateUserRequest)) postgresflex.ApiPartialUpdateUserRequest {
- request := testClient.PartialUpdateUser(testCtx, testProjectId, testInstanceId, testUserId)
+ request := testClient.PartialUpdateUser(testCtx, testProjectId, testRegion, testInstanceId, testUserId)
request = request.PartialUpdateUserPayload(postgresflex.PartialUpdateUserPayload{
Roles: utils.Ptr([]string{"login"}),
})
@@ -105,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,
},
@@ -113,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,
},
@@ -121,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,
},
@@ -167,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)
})
}
}
@@ -227,7 +181,7 @@ func TestBuildRequest(t *testing.T) {
}{
{
description: "base",
- model: fixtureInputModel(func(model *inputModel) {}),
+ model: fixtureInputModel(),
expectedRequest: fixtureRequest(),
},
}
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 aa68e5290..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"
@@ -22,28 +22,37 @@ import (
)
const (
- parentIdFlag = "parent-id"
- nameFlag = "name"
- labelFlag = "label"
-
- ownerRole = "project.owner"
- labelKeyRegex = `[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`
- labelValueRegex = `^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`
+ parentIdFlag = "parent-id"
+ nameFlag = "name"
+ labelFlag = "label"
+ networkAreaIdFlag = "network-area-id"
+
+ ownerRole = "project.owner"
+ labelKeyRegex = `[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`
+ labelValueRegex = `^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}`
+ networkAreaLabel = "networkArea"
)
type inputModel struct {
*globalflags.GlobalFlagModel
- ParentId *string
- Name *string
- Labels *map[string]string
+ ParentId *string
+ Name *string
+ Labels *map[string]string
+ 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",
- Long: "Creates a STACKIT project.",
- Args: args.NoArgs,
+ Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n",
+ "Creates a STACKIT project.",
+ "You can associate a project with a STACKIT Network Area (SNA) by providing the ID of the SNA.",
+ "The STACKIT Network Area (SNA) allows projects within an organization to be connected to each other on a network level.",
+ "This makes it possible to connect various resources of the projects within an SNA and also simplifies the connection with on-prem environments (hybrid cloud).",
+ "The network type can no longer be changed after the project has been created. If you require a different network type, you must create a new project.",
+ ),
+ Args: args.NoArgs,
Example: examples.Build(
examples.NewExample(
`Create a STACKIT project`,
@@ -51,26 +60,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
examples.NewExample(
`Create a STACKIT project with a set of labels`,
"$ stackit project create --parent-id xxxx --name my-project --label key=value --label foo=bar"),
+ examples.NewExample(
+ `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, 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
@@ -83,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)
@@ -94,12 +104,13 @@ func configureFlags(cmd *cobra.Command) {
cmd.Flags().String(parentIdFlag, "", "Parent resource identifier. Both container ID (user-friendly) and UUID are supported")
cmd.Flags().String(nameFlag, "", "Project name")
cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a project. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels")
+ cmd.Flags().Var(flags.UUIDFlag(), networkAreaIdFlag, "ID of a STACKIT Network Area (SNA) to associate with the project.")
err := flags.MarkFlagsRequired(cmd, parentIdFlag, 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)
labels := flags.FlagToStringToStringPointer(p, cmd, labelFlag)
@@ -128,17 +139,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
ParentId: flags.FlagToStringPointer(p, cmd, parentIdFlag),
Name: flags.FlagToStringPointer(p, cmd, nameFlag),
Labels: labels,
+ 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
}
@@ -174,10 +178,19 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *resourceman
return req, fmt.Errorf("the authenticated subject email cannot be empty, please report this issue")
}
+ labels := model.Labels
+
+ if model.NetworkAreaId != nil {
+ if labels == nil {
+ labels = &map[string]string{}
+ }
+ (*labels)[networkAreaLabel] = *model.NetworkAreaId
+ }
+
req = req.CreateProjectPayload(resourcemanager.CreateProjectPayload{
ContainerParentId: model.ParentId,
Name: model.Name,
- Labels: model.Labels,
+ Labels: labels,
Members: &[]resourcemanager.Member{
{
Role: utils.Ptr(ownerRole),
@@ -189,26 +202,15 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *resourceman
return req, nil
}
-func outputResult(p *print.Printer, model *inputModel, resp *resourcemanager.Project) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal project: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
- p.Outputf("Created project under the parent with ID %q. Project ID: %s\n", *model.ParentId, *resp.ProjectId)
- return nil
+func outputResult(p *print.Printer, model inputModel, resp *resourcemanager.Project) error {
+ if resp == nil {
+ return fmt.Errorf("response is empty")
}
+ if model.GlobalFlagModel == nil {
+ return fmt.Errorf("globalflags are empty")
+ }
+ 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 e1e6365ad..095469298 100644
--- a/internal/cmd/project/create/create_test.go
+++ b/internal/cmd/project/create/create_test.go
@@ -4,14 +4,16 @@ import (
"context"
"testing"
- "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"
+ "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/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"
)
@@ -21,13 +23,15 @@ type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &resourcemanager.APIClient{}
var testParentId = uuid.NewString()
+var testNetworkAreaId = uuid.NewString()
var testEmail = "email"
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
- parentIdFlag: testParentId,
- nameFlag: "name",
- labelFlag: "key=value",
+ parentIdFlag: testParentId,
+ nameFlag: "name",
+ labelFlag: "key=value",
+ networkAreaIdFlag: testNetworkAreaId,
}
for _, mod := range mods {
mod(flagValues)
@@ -43,6 +47,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
Labels: utils.Ptr(map[string]string{
"key": "value",
}),
+ NetworkAreaId: utils.Ptr(testNetworkAreaId),
}
for _, mod := range mods {
mod(model)
@@ -50,13 +55,13 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *resourcemanager.ApiCreateProjectRequest)) resourcemanager.ApiCreateProjectRequest {
- request := testClient.CreateProject(testCtx)
- request = request.CreateProjectPayload(resourcemanager.CreateProjectPayload{
+func fixturePayload(mods ...func(payload *resourcemanager.CreateProjectPayload)) resourcemanager.CreateProjectPayload {
+ payload := resourcemanager.CreateProjectPayload{
ContainerParentId: utils.Ptr(testParentId),
Name: utils.Ptr(nameFlag),
Labels: utils.Ptr(map[string]string{
- "key": "value",
+ "key": "value",
+ networkAreaLabel: testNetworkAreaId,
}),
Members: &[]resourcemanager.Member{
{
@@ -64,7 +69,16 @@ func fixtureRequest(mods ...func(request *resourcemanager.ApiCreateProjectReques
Subject: utils.Ptr(testEmail),
},
},
- })
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *resourcemanager.ApiCreateProjectRequest)) resourcemanager.ApiCreateProjectRequest {
+ request := testClient.CreateProject(testCtx)
+ request = request.CreateProjectPayload(fixturePayload())
for _, mod := range mods {
mod(&request)
}
@@ -74,6 +88,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
@@ -136,60 +151,38 @@ func TestParseInput(t *testing.T) {
labelValues: []string{"key"},
isValid: false,
},
+ {
+ description: "network_area_id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkAreaIdFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(
+ func(model *inputModel) {
+ model.NetworkAreaId = nil
+ }),
+ },
+ {
+ description: "network_area_id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkAreaIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "network_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) {
- 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)
})
}
}
@@ -228,6 +221,51 @@ func TestBuildRequest(t *testing.T) {
expectedRequest: fixtureRequest(),
isValid: true,
},
+ {
+ description: "missing_network_area_id sa_key",
+ model: fixtureInputModel(
+ func(model *inputModel) {
+ model.NetworkAreaId = nil
+ }),
+ authFlow: auth.AUTH_FLOW_SERVICE_ACCOUNT_KEY,
+ sa_email: utils.Ptr(testEmail),
+ expectedRequest: fixtureRequest().CreateProjectPayload(fixturePayload(
+ func(payload *resourcemanager.CreateProjectPayload) {
+ delete((*payload.Labels), networkAreaLabel)
+ }),
+ ),
+ isValid: true,
+ },
+ {
+ description: "missing_network_area_id sa_token",
+ model: fixtureInputModel(
+ func(model *inputModel) {
+ model.NetworkAreaId = nil
+ }),
+ authFlow: auth.AUTH_FLOW_SERVICE_ACCOUNT_TOKEN,
+ sa_email: utils.Ptr(testEmail),
+ expectedRequest: fixtureRequest().CreateProjectPayload(fixturePayload(
+ func(payload *resourcemanager.CreateProjectPayload) {
+ delete((*payload.Labels), networkAreaLabel)
+ }),
+ ),
+ isValid: true,
+ },
+ {
+ description: "missing_network_area_id user",
+ model: fixtureInputModel(
+ func(model *inputModel) {
+ model.NetworkAreaId = nil
+ }),
+ authFlow: auth.AUTH_FLOW_USER_TOKEN,
+ user_email: utils.Ptr(testEmail),
+ expectedRequest: fixtureRequest().CreateProjectPayload(fixturePayload(
+ func(payload *resourcemanager.CreateProjectPayload) {
+ delete((*payload.Labels), networkAreaLabel)
+ }),
+ ),
+ isValid: true,
+ },
{
description: "missing_auth_flow",
model: fixtureInputModel(),
@@ -277,3 +315,28 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ model inputModel
+ resp *resourcemanager.Project
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{model: inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}}, true},
+ {"base", args{inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, &resourcemanager.Project{}}, 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.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/project/delete/delete.go b/internal/cmd/project/delete/delete.go
index 12951b4ec..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",
@@ -36,29 +38,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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(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 69662f53f..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
}
@@ -119,39 +111,28 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *resourceman
}
func outputResult(p *print.Printer, outputFormat string, project *resourcemanager.GetProjectResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal project details: %w", err)
- }
- p.Outputln(string(details))
+ if project == nil {
+ return fmt.Errorf("response not set")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, project, func() error {
table := tables.NewTable()
- table.AddRow("ID", *project.ProjectId)
+ table.AddRow("ID", utils.PtrString(project.ProjectId))
table.AddSeparator()
- table.AddRow("NAME", *project.Name)
+ table.AddRow("NAME", utils.PtrString(project.Name))
table.AddSeparator()
- table.AddRow("CREATION", *project.CreationTime)
+ table.AddRow("CREATION", utils.PtrString(project.CreationTime))
table.AddSeparator()
- table.AddRow("STATE", *project.LifecycleState)
+ table.AddRow("STATE", utils.PtrString(project.LifecycleState))
table.AddSeparator()
- table.AddRow("PARENT ID", *project.Parent.Id)
+ if project.Parent != nil {
+ table.AddRow("PARENT ID", utils.PtrString(project.Parent.Id))
+ }
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/project/describe/describe_test.go b/internal/cmd/project/describe/describe_test.go
index 8d8cd7490..b5f3ddcff 100644
--- a/internal/cmd/project/describe/describe_test.go
+++ b/internal/cmd/project/describe/describe_test.go
@@ -3,13 +3,17 @@ package describe
import (
"context"
"testing"
+ "time"
- "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
- "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/resourcemanager"
)
@@ -135,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 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,3 +171,35 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ project *resourcemanager.GetProjectResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, true},
+ {"base", args{"", &resourcemanager.GetProjectResponse{}}, false},
+ {"complete", args{"", &resourcemanager.GetProjectResponse{
+ ProjectId: utils.Ptr("4711"),
+ Name: utils.Ptr("name"),
+ CreationTime: utils.Ptr(time.Now()),
+ LifecycleState: utils.Ptr(resourcemanager.LIFECYCLESTATE_CREATING),
+ Parent: &resourcemanager.Parent{Id: utils.Ptr("parent id")},
+ },
+ }, 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.project); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/project/list/list.go b/internal/cmd/project/list/list.go
index cb2d0b141..d4ef99b63 100644
--- a/internal/cmd/project/list/list.go
+++ b/internal/cmd/project/list/list.go
@@ -2,11 +2,12 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/auth"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
@@ -16,8 +17,7 @@ import (
"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/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/resourcemanager"
)
@@ -43,7 +43,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",
@@ -65,13 +65,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +82,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 +102,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 +139,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
}
@@ -167,7 +159,7 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient resourceMana
}
if model.ParentId == nil && model.ProjectIdLike == nil && model.Member == nil {
- email, err := auth.GetAuthField(auth.USER_EMAIL)
+ email, err := auth.GetAuthEmail()
if err != nil {
return req, fmt.Errorf("get email of authenticated user: %w", err)
}
@@ -220,29 +212,22 @@ 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))
- 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 {
p := projects[i]
- table.AddRow(*p.ProjectId, *p.Name, *p.LifecycleState, *p.Parent.Id)
+
+ var parentId *string
+ if p.Parent != nil {
+ parentId = p.Parent.Id
+ }
+ table.AddRow(
+ utils.PtrString(p.ProjectId),
+ utils.PtrString(p.Name),
+ utils.PtrString(p.LifecycleState),
+ utils.PtrString(parentId),
+ )
}
err := table.Display(p)
@@ -251,5 +236,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 bf0e2d68c..8050a0f27 100644
--- a/internal/cmd/project/list/list_test.go
+++ b/internal/cmd/project/list/list_test.go
@@ -9,17 +9,20 @@ import (
"testing"
"time"
- "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"
- "github.com/zalando/go-keyring"
+ "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-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{}
@@ -67,7 +70,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")
@@ -81,6 +84,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
@@ -194,73 +198,21 @@ 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)
})
}
}
func TestBuildRequest(t *testing.T) {
keyring.MockInit()
- err := auth.SetAuthField(auth.USER_EMAIL, "test@test.com")
+ err := auth.SetAuthFlow(auth.AUTH_FLOW_USER_TOKEN)
+ if err != nil {
+ t.Fatalf("Failed to set auth flow: %v", err)
+ }
+
+ err = auth.SetAuthField(auth.USER_EMAIL, "test@test.com")
if err != nil {
t.Fatalf("Failed to set auth user email: %v", err)
}
@@ -483,7 +435,7 @@ func TestFetchProjects(t *testing.T) {
}
return
}
- if err == nil && tt.apiCallFails {
+ if tt.apiCallFails {
t.Fatalf("did not fail on invalid input")
}
if numAPICalls != tt.expectedNumAPICalls {
@@ -495,3 +447,53 @@ func TestFetchProjects(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projects []resourcemanager.Project
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, false},
+ {"base", args{"", []resourcemanager.Project{{}}}, false},
+ {"complete", args{"", []resourcemanager.Project{
+ {
+ ContainerId: utils.Ptr("container-id1"),
+ CreationTime: utils.Ptr(time.Now()),
+ Labels: &map[string]string{"foo": "bar"},
+ LifecycleState: utils.Ptr(resourcemanager.LIFECYCLESTATE_CREATING),
+ Name: utils.Ptr("some name"),
+ Parent: &resourcemanager.Parent{
+ Id: utils.Ptr("parent-id"),
+ },
+ ProjectId: utils.Ptr("project-id1"),
+ },
+ {
+ ContainerId: utils.Ptr("container-id2"),
+ CreationTime: utils.Ptr(time.Now()),
+ Labels: &map[string]string{"foo": "bar"},
+ LifecycleState: utils.Ptr(resourcemanager.LIFECYCLESTATE_CREATING),
+ Name: utils.Ptr("some name"),
+ Parent: &resourcemanager.Parent{
+ Id: utils.Ptr("parent-id"),
+ },
+ ProjectId: utils.Ptr("project-id2"),
+ },
+ }}, 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.projects); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/project/member/add/add.go b/internal/cmd/project/member/add/add.go
index d1deff0e8..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", *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 6171a0b4e..66df41524 100644
--- a/internal/cmd/project/member/list/list.go
+++ b/internal/cmd/project/member/list/list.go
@@ -2,11 +2,12 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -16,8 +17,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/authorization/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
)
@@ -37,7 +37,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",
@@ -56,13 +56,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +75,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 +89,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 +104,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,58 +125,35 @@ 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)
}
return req
}
-func outputResult(p *print.Printer, model *inputModel, members []authorization.Member) error {
+func outputResult(p *print.Printer, model inputModel, members []authorization.Member) error {
+ if model.GlobalFlagModel == nil {
+ return fmt.Errorf("globalflags are empty")
+ }
sortFn := func(i, j int) bool {
switch model.SortBy {
case "subject":
- return *members[i].Subject < *members[j].Subject
+ return utils.PtrString(members[i].Subject) < utils.PtrString(members[j].Subject)
case "role":
- return *members[i].Role < *members[j].Role
+ return utils.PtrString(members[i].Role) < utils.PtrString(members[j].Role)
default:
return false
}
}
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))
- 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 {
@@ -185,12 +162,13 @@ func outputResult(p *print.Printer, model *inputModel, members []authorization.M
if i > 0 && sortFn(i-1, i) {
table.AddSeparator()
}
- table.AddRow(*m.Subject, *m.Role)
+ 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)
}
@@ -200,5 +178,5 @@ func outputResult(p *print.Printer, model *inputModel, members []authorization.M
}
return nil
- }
+ })
}
diff --git a/internal/cmd/project/member/list/list_test.go b/internal/cmd/project/member/list/list_test.go
index 1e4050d4e..af050ba78 100644
--- a/internal/cmd/project/member/list/list_test.go
+++ b/internal/cmd/project/member/list/list_test.go
@@ -4,14 +4,15 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
)
@@ -60,6 +61,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
@@ -129,48 +131,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)
})
}
}
@@ -209,3 +170,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ model inputModel
+ members []authorization.Member
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{model: inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}}, false},
+ {"base", args{inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ Subject: utils.Ptr("subject"),
+ Limit: nil,
+ SortBy: "",
+ }, nil}, false},
+ {"complete", args{inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ Subject: utils.Ptr("subject"),
+ Limit: nil,
+ SortBy: "",
+ },
+ []authorization.Member{
+ {Role: utils.Ptr("role1"), Subject: utils.Ptr("subject1")},
+ {Role: utils.Ptr("role2"), Subject: utils.Ptr("subject2")},
+ {Role: utils.Ptr("role3"), Subject: utils.Ptr("subject3")},
+ }},
+ 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.members); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 2c7142b6b..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", *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 b00c10b4a..7be67dc6b 100644
--- a/internal/cmd/project/role/list/list.go
+++ b/internal/cmd/project/role/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/authorization/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
)
@@ -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 roles and permissions of a project",
@@ -51,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +70,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 +84,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 +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,49 +114,28 @@ 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))
- 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 {
r := roles[i]
for j := range *r.Permissions {
p := (*r.Permissions)[j]
- table.AddRow(*r.Name, *r.Description, *p.Name, *p.Description)
+ table.AddRow(
+ utils.PtrString(r.Name),
+ utils.PtrString(r.Description),
+ utils.PtrString(p.Name),
+ utils.PtrString(p.Description),
+ )
}
table.AddSeparator()
}
@@ -167,5 +146,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 59c8f9855..3bc59db9a 100644
--- a/internal/cmd/project/role/list/list_test.go
+++ b/internal/cmd/project/role/list/list_test.go
@@ -4,14 +4,15 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/authorization"
)
@@ -59,6 +60,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
@@ -99,48 +101,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)
})
}
}
@@ -172,3 +133,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputRolesResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ roles []authorization.Role
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {"empty", args{}, false},
+ {"standard", args{"", nil}, false},
+ {"complete", args{"", []authorization.Role{
+ {
+ Description: utils.Ptr("description"),
+ Id: utils.Ptr("id"),
+ Name: utils.Ptr("name"),
+ Permissions: &[]authorization.Permission{
+ {Description: utils.Ptr("description"), Name: utils.Ptr("name")},
+ },
+ },
+ }}, 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 := outputRolesResult(p, tt.args.outputFormat, tt.args.roles); (err != nil) != tt.wantErr {
+ t.Errorf("outputRolesResult() error = %v, wantErr %v", err, 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 34499de26..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",
@@ -55,29 +57,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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
new file mode 100644
index 000000000..357a6c87f
--- /dev/null
+++ b/internal/cmd/public-ip/associate/associate.go
@@ -0,0 +1,122 @@
+package associate
+
+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"
+ "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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ publicIpIdArg = "PUBLIC_IP_ID"
+
+ associatedResourceIdFlag = "associated-resource-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ PublicIpId string
+ AssociatedResourceId *string
+}
+
+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",
+ Long: "Associates a Public IP with a network interface or a virtual IP.",
+ Args: args.SingleArg(publicIpIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Associate public IP with ID "xxx" to a resource (network interface or virtual IP) with ID "yyy"`,
+ `$ stackit public-ip associate xxx --associated-resource-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
+ }
+
+ publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get public IP: %v", err)
+ publicIpLabel = model.PublicIpId
+ } else if publicIpLabel == "" {
+ publicIpLabel = model.PublicIpId
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("associate public IP: %w", err)
+ }
+
+ params.Printer.Outputf("Associated public IP %q with resource %v.\n", publicIpLabel, utils.PtrString(resp.GetNetworkInterface()))
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), associatedResourceIdFlag, "Associates the public IP with a network interface or virtual IP (ID)")
+
+ err := flags.MarkFlagsRequired(cmd, associatedResourceIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ publicIpId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ AssociatedResourceId: flags.FlagToStringPointer(p, cmd, associatedResourceIdFlag),
+ PublicIpId: publicIpId,
+ }
+
+ 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.Region, model.PublicIpId)
+
+ payload := iaas.UpdatePublicIPPayload{
+ NetworkInterface: iaas.NewNullableString(model.AssociatedResourceId),
+ }
+
+ return req.UpdatePublicIPPayload(payload)
+}
diff --git a/internal/cmd/public-ip/associate/associate_test.go b/internal/cmd/public-ip/associate/associate_test.go
new file mode 100644
index 000000000..8d40c1a6a
--- /dev/null
+++ b/internal/cmd/public-ip/associate/associate_test.go
@@ -0,0 +1,253 @@
+package associate
+
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testPublicIpId = uuid.NewString()
+var testAssociatedResourceId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testPublicIpId,
+ }
+ 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,
+
+ associatedResourceIdFlag: testAssociatedResourceId,
+ }
+ 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,
+ },
+ PublicIpId: testPublicIpId,
+ AssociatedResourceId: utils.Ptr(testAssociatedResourceId),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdatePublicIPRequest)) iaas.ApiUpdatePublicIPRequest {
+ request := testClient.UpdatePublicIP(testCtx, testProjectId, testRegion, testPublicIpId)
+ request = request.UpdatePublicIPPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdatePublicIPPayload)) iaas.UpdatePublicIPPayload {
+ payload := iaas.UpdatePublicIPPayload{
+ NetworkInterface: iaas.NewNullableString(utils.Ptr(testAssociatedResourceId)),
+ }
+ 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: "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: "public ip id invalid 1",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "public ip id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "associated resource id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, associatedResourceIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "associated resource id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[associatedResourceIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "associated resource id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[associatedResourceIdFlag] = "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.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 iaas.ApiUpdatePublicIPRequest
+ }{
+ {
+ 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(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/public-ip/create/create.go b/internal/cmd/public-ip/create/create.go
new file mode 100644
index 000000000..a13574190
--- /dev/null
+++ b/internal/cmd/public-ip/create/create.go
@@ -0,0 +1,131 @@
+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/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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ associatedResourceIdFlag = "associated-resource-id"
+ labelFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ AssociatedResourceId *string
+ Labels *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a Public IP",
+ Long: "Creates a Public IP.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a public IP`,
+ `$ stackit public-ip create`,
+ ),
+ examples.NewExample(
+ `Create a public IP with associated resource ID "xxx"`,
+ `$ stackit public-ip create --associated-resource-id xxx`,
+ ),
+ examples.NewExample(
+ `Create a public IP with associated resource ID "xxx" and labels`,
+ `$ stackit public-ip create --associated-resource-id xxx --labels key=value,foo=bar`,
+ ),
+ ),
+ 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 public IP 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 public IP: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, projectLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), associatedResourceIdFlag, "Associates the public IP with a network interface or virtual IP (ID)")
+ 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, _ []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ AssociatedResourceId: flags.FlagToStringPointer(p, cmd, associatedResourceIdFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreatePublicIPRequest {
+ req := apiClient.CreatePublicIP(ctx, model.ProjectId, model.Region)
+
+ payload := iaas.CreatePublicIPPayload{
+ NetworkInterface: iaas.NewNullableString(model.AssociatedResourceId),
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+
+ return req.CreatePublicIPPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, projectLabel string, publicIp iaas.PublicIp) error {
+ 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
new file mode 100644
index 000000000..a4d0a4b23
--- /dev/null
+++ b/internal/cmd/public-ip/create/create_test.go
@@ -0,0 +1,213 @@
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testAssociatedResourceId = uuid.NewString()
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ associatedResourceIdFlag: testAssociatedResourceId,
+ labelFlag: "key=value",
+ }
+ 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,
+ },
+ AssociatedResourceId: utils.Ptr(testAssociatedResourceId),
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreatePublicIPRequest)) iaas.ApiCreatePublicIPRequest {
+ request := testClient.CreatePublicIP(testCtx, testProjectId, testRegion)
+ request = request.CreatePublicIPPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreatePublicIPPayload)) iaas.CreatePublicIPPayload {
+ payload := iaas.CreatePublicIPPayload{
+ NetworkInterface: iaas.NewNullableString(utils.Ptr(testAssociatedResourceId)),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ }
+ 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, associatedResourceIdFlag)
+ delete(flagValues, labelFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.AssociatedResourceId = nil
+ model.Labels = nil
+ }),
+ },
+ {
+ description: "valid with associated resource id",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.AssociatedResourceId = utils.Ptr(testAssociatedResourceId)
+ model.Labels = nil
+ }),
+ },
+ {
+ 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,
+ },
+ }
+
+ 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.ApiCreatePublicIPRequest
+ }{
+ {
+ 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(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ publicIp iaas.PublicIp
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.projectLabel, tt.args.publicIp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/public-ip/delete/delete.go b/internal/cmd/public-ip/delete/delete.go
new file mode 100644
index 000000000..2bf9c234f
--- /dev/null
+++ b/internal/cmd/public-ip/delete/delete.go
@@ -0,0 +1,105 @@
+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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ publicIpIdArg = "PUBLIC_IP_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ PublicIpId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", publicIpIdArg),
+ Short: "Deletes a Public IP",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Deletes a Public IP.",
+ "If the public IP is still in use, the deletion will fail",
+ ),
+ Args: args.SingleArg(publicIpIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete public IP with ID "xxx"`,
+ "$ stackit public-ip 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
+ }
+
+ publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get public IP: %v", err)
+ publicIpLabel = model.PublicIpId
+ } else if publicIpLabel == "" {
+ publicIpLabel = model.PublicIpId
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete public IP: %w", err)
+ }
+
+ params.Printer.Info("Deleted public IP %q\n", publicIpLabel)
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ publicIpId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ PublicIpId: publicIpId,
+ }
+
+ 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.Region, model.PublicIpId)
+}
diff --git a/internal/cmd/public-ip/delete/delete_test.go b/internal/cmd/public-ip/delete/delete_test.go
new file mode 100644
index 000000000..25290233e
--- /dev/null
+++ b/internal/cmd/public-ip/delete/delete_test.go
@@ -0,0 +1,175 @@
+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")
+var testClient = &iaas.APIClient{}
+var testPublicIpId = uuid.NewString()
+var testProjectId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testPublicIpId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ PublicIpId: testPublicIpId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeletePublicIPRequest)) iaas.ApiDeletePublicIPRequest {
+ request := testClient.DeletePublicIP(testCtx, testProjectId, testRegion, testPublicIpId)
+ 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: "public ip id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "public ip 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 iaas.ApiDeletePublicIPRequest
+ }{
+ {
+ 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/public-ip/describe/describe.go b/internal/cmd/public-ip/describe/describe.go
new file mode 100644
index 000000000..fbcc2b15b
--- /dev/null
+++ b/internal/cmd/public-ip/describe/describe.go
@@ -0,0 +1,123 @@
+package describe
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+)
+
+const (
+ publicIpIdArg = "PUBLIC_IP_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ PublicIpId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", publicIpIdArg),
+ Short: "Shows details of a Public IP",
+ Long: "Shows details of a Public IP.",
+ Args: args.SingleArg(publicIpIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a public IP with ID "xxx"`,
+ "$ stackit public-ip describe xxx",
+ ),
+ examples.NewExample(
+ `Show details of a public IP with ID "xxx" in JSON format`,
+ "$ stackit public-ip 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 public IP: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ publicIpId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ PublicIpId: publicIpId,
+ }
+
+ 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.Region, model.PublicIpId)
+}
+
+func outputResult(p *print.Printer, outputFormat string, publicIp iaas.PublicIp) error {
+ return p.OutputResult(outputFormat, publicIp, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(publicIp.Id))
+ table.AddSeparator()
+ table.AddRow("IP ADDRESS", utils.PtrString(publicIp.Ip))
+ table.AddSeparator()
+
+ if publicIp.NetworkInterface != nil {
+ networkInterfaceId := *publicIp.GetNetworkInterface()
+ table.AddRow("ASSOCIATED TO", networkInterfaceId)
+ table.AddSeparator()
+ }
+
+ if publicIp.Labels != nil && len(*publicIp.Labels) > 0 {
+ labels := []string{}
+ for key, value := range *publicIp.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..d140e36bf
--- /dev/null
+++ b/internal/cmd/public-ip/describe/describe_test.go
@@ -0,0 +1,205 @@
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testPublicIpId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testPublicIpId,
+ }
+ 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,
+ },
+ PublicIpId: testPublicIpId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetPublicIPRequest)) iaas.ApiGetPublicIPRequest {
+ request := testClient.GetPublicIP(testCtx, testProjectId, testRegion, testPublicIpId)
+ 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: "public ip id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "public ip 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 iaas.ApiGetPublicIPRequest
+ }{
+ {
+ 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
+ publicIp iaas.PublicIp
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.publicIp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/public-ip/disassociate/disassociate.go b/internal/cmd/public-ip/disassociate/disassociate.go
new file mode 100644
index 000000000..7df01b637
--- /dev/null
+++ b/internal/cmd/public-ip/disassociate/disassociate.go
@@ -0,0 +1,109 @@
+package disassociate
+
+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"
+ "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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ publicIpIdArg = "PUBLIC_IP_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ PublicIpId string
+}
+
+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",
+ Long: "Disassociates a Public IP from a network interface or a virtual IP.",
+ Args: args.SingleArg(publicIpIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Disassociate public IP with ID "xxx" from a resource (network interface or virtual IP)`,
+ `$ stackit public-ip disassociate 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
+ }
+
+ publicIpLabel, associatedResourceId, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get public IP: %v", err)
+ publicIpLabel = model.PublicIpId
+ } else if publicIpLabel == "" {
+ publicIpLabel = model.PublicIpId
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ _, err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("disassociate public IP: %w", err)
+ }
+
+ params.Printer.Outputf("Disassociated public IP %q from the associated resource %q.\n", publicIpLabel, associatedResourceId)
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ publicIpId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ PublicIpId: publicIpId,
+ }
+
+ 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.Region, model.PublicIpId)
+
+ payload := iaas.UpdatePublicIPPayload{
+ NetworkInterface: iaas.NewNullableString(nil),
+ }
+
+ return req.UpdatePublicIPPayload(payload)
+}
diff --git a/internal/cmd/argus/instance/delete/delete_test.go b/internal/cmd/public-ip/disassociate/disassociate_test.go
similarity index 74%
rename from internal/cmd/argus/instance/delete/delete_test.go
rename to internal/cmd/public-ip/disassociate/disassociate_test.go
index 0510769ee..41c8d26e8 100644
--- a/internal/cmd/argus/instance/delete/delete_test.go
+++ b/internal/cmd/public-ip/disassociate/disassociate_test.go
@@ -1,30 +1,35 @@
-package delete
+package disassociate
import (
"context"
"testing"
- "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "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-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
-var projectIdFlag = globalflags.ProjectIdFlag
+const (
+ testRegion = "eu01"
+)
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &argus.APIClient{}
+var testClient = &iaas.APIClient{}
+
var testProjectId = uuid.NewString()
-var testInstanceId = uuid.NewString()
+var testPublicIpId = uuid.NewString()
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
- testInstanceId,
+ testPublicIpId,
}
for _, mod := range mods {
mod(argValues)
@@ -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,8 +53,9 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
},
- InstanceId: testInstanceId,
+ PublicIpId: testPublicIpId,
}
for _, mod := range mods {
mod(model)
@@ -56,14 +63,25 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *argus.ApiDeleteInstanceRequest)) argus.ApiDeleteInstanceRequest {
- request := testClient.DeleteInstance(testCtx, testInstanceId, testProjectId)
+func fixtureRequest(mods ...func(request *iaas.ApiUpdatePublicIPRequest)) iaas.ApiUpdatePublicIPRequest {
+ request := testClient.UpdatePublicIP(testCtx, testProjectId, testRegion, testPublicIpId)
+ request = request.UpdatePublicIPPayload(fixturePayload())
for _, mod := range mods {
mod(&request)
}
return request
}
+func fixturePayload(mods ...func(payload *iaas.UpdatePublicIPPayload)) iaas.UpdatePublicIPPayload {
+ payload := iaas.UpdatePublicIPPayload{
+ NetworkInterface: iaas.NewNullableString(nil),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
func TestParseInput(t *testing.T) {
tests := []struct {
description string
@@ -85,23 +103,11 @@ func TestParseInput(t *testing.T) {
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)
+ delete(flagValues, globalflags.ProjectIdFlag)
}),
isValid: false,
},
@@ -109,7 +115,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,18 +123,18 @@ 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,
},
{
- description: "instance id invalid 1",
- argValues: []string{""},
+ description: "public ip id invalid 1",
+ argValues: []string{},
flagValues: fixtureFlagValues(),
isValid: false,
},
{
- description: "instance id invalid 2",
+ description: "public ip id invalid 2",
argValues: []string{"invalid-uuid"},
flagValues: fixtureFlagValues(),
isValid: false,
@@ -138,7 +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)
+ cmd := NewCmd(&types.CmdParams{Printer: p})
err := globalflags.Configure(cmd.Flags())
if err != nil {
t.Fatalf("configure global flags: %v", err)
@@ -154,20 +160,20 @@ func TestParseInput(t *testing.T) {
}
}
- err = cmd.ValidateArgs(tt.argValues)
+ err = cmd.ValidateRequiredFlags()
if err != nil {
if !tt.isValid {
return
}
- t.Fatalf("error validating args: %v", err)
+ t.Fatalf("error validating flags: %v", err)
}
- err = cmd.ValidateRequiredFlags()
+ err = cmd.ValidateArgs(tt.argValues)
if err != nil {
if !tt.isValid {
return
}
- t.Fatalf("error validating flags: %v", err)
+ t.Fatalf("error validating args: %v", err)
}
model, err := parseInput(p, cmd, tt.argValues)
@@ -175,7 +181,7 @@ func TestParseInput(t *testing.T) {
if !tt.isValid {
return
}
- t.Fatalf("error parsing input: %v", err)
+ t.Fatalf("error parsing flags: %v", err)
}
if !tt.isValid {
@@ -193,7 +199,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest argus.ApiDeleteInstanceRequest
+ expectedRequest iaas.ApiUpdatePublicIPRequest
}{
{
description: "base",
@@ -209,6 +215,7 @@ func TestBuildRequest(t *testing.T) {
diff := cmp.Diff(request, tt.expectedRequest,
cmp.AllowUnexported(tt.expectedRequest),
cmpopts.EquateComparable(testCtx),
+ cmp.AllowUnexported(iaas.NullableString{}),
)
if diff != "" {
t.Fatalf("Data does not match: %s", diff)
diff --git a/internal/cmd/public-ip/list/list.go b/internal/cmd/public-ip/list/list.go
new file mode 100644
index 000000000..b2acfff92
--- /dev/null
+++ b/internal/cmd/public-ip/list/list.go
@@ -0,0 +1,160 @@
+package list
+
+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"
+ "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"
+)
+
+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 Public IPs of a project",
+ Long: "Lists all Public IPs of a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all public IPs`,
+ "$ stackit public-ip list",
+ ),
+ examples.NewExample(
+ `Lists all public IPs which contains the label xxx`,
+ "$ stackit public-ip list --label-selector xxx",
+ ),
+ examples.NewExample(
+ `Lists all public IPs in JSON format`,
+ "$ stackit public-ip list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 public IPs`,
+ "$ stackit public-ip 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("list public IPs: %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
+ } else if projectLabel == "" {
+ projectLabel = model.ProjectId
+ }
+ params.Printer.Info("No public IPs found for project %q\n", projectLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListPublicIPsRequest {
+ req := apiClient.ListPublicIPs(ctx, model.ProjectId, model.Region)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, publicIps []iaas.PublicIp) error {
+ return p.OutputResult(outputFormat, publicIps, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "IP ADDRESS", "USED BY")
+
+ for _, publicIp := range publicIps {
+ networkInterfaceId := utils.PtrStringDefault(publicIp.GetNetworkInterface(), "")
+ table.AddRow(
+ utils.PtrString(publicIp.Id),
+ utils.PtrString(publicIp.Ip),
+ networkInterfaceId,
+ )
+ table.AddSeparator()
+ }
+
+ 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
new file mode 100644
index 000000000..9a10067d9
--- /dev/null
+++ b/internal/cmd/public-ip/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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testLabelSelector = "label"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ limitFlag: "10",
+ labelSelectorFlag: testLabelSelector,
+ }
+ 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,
+ },
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListPublicIPsRequest)) iaas.ApiListPublicIPsRequest {
+ request := testClient.ListPublicIPs(testCtx, testProjectId, testRegion)
+ request = request.LabelSelector(testLabelSelector)
+ 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 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: "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: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ 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.ApiListPublicIPsRequest
+ }{
+ {
+ 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
+ publicIps []iaas.PublicIp
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.publicIps); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/public-ip/public-ip.go b/internal/cmd/public-ip/public-ip.go
new file mode 100644
index 000000000..77a4e3a2b
--- /dev/null
+++ b/internal/cmd/public-ip/public-ip.go
@@ -0,0 +1,40 @@
+package publicip
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/associate"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip/delete"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "public-ip",
+ Short: "Provides functionality for public IPs",
+ Long: "Provides functionality for public IPs.",
+ 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))
+ 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..918cb2041
--- /dev/null
+++ b/internal/cmd/public-ip/ranges/list/list.go
@@ -0,0 +1,123 @@
+package list
+
+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"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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..9a50dfeb1
--- /dev/null
+++ b/internal/cmd/public-ip/ranges/list/list_test.go
@@ -0,0 +1,191 @@
+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-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"
+)
+
+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
new file mode 100644
index 000000000..8fb1d8a77
--- /dev/null
+++ b/internal/cmd/public-ip/update/update.go
@@ -0,0 +1,132 @@
+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"
+ 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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ publicIpIdArg = "PUBLIC_IP_ID"
+
+ labelFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ PublicIpId string
+ Labels *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", publicIpIdArg),
+ Short: "Updates a Public IP",
+ Long: "Updates a Public IP.",
+ Args: args.SingleArg(publicIpIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update public IP with ID "xxx"`,
+ `$ stackit public-ip update xxx`,
+ ),
+ examples.NewExample(
+ `Update public IP with ID "xxx" with new labels`,
+ `$ stackit public-ip update xxx --labels key=value,foo=bar`,
+ ),
+ ),
+ 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
+ }
+
+ publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get public IP: %v", err)
+ publicIpLabel = model.PublicIpId
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update public IP: %w", err)
+ }
+
+ return outputResult(params.Printer, model, publicIpLabel, 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 public IP. E.g. '--labels key1=value1,key2=value2,...'")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ publicIpId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ labels := flags.FlagToStringToStringPointer(p, cmd, labelFlag)
+
+ if labels == nil {
+ return nil, &cliErr.EmptyUpdateError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ PublicIpId: publicIpId,
+ Labels: labels,
+ }
+
+ 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.Region, model.PublicIpId)
+
+ payload := iaas.UpdatePublicIPPayload{
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+
+ return req.UpdatePublicIPPayload(payload)
+}
+
+func outputResult(p *print.Printer, model *inputModel, publicIpLabel string, publicIp *iaas.PublicIp) error {
+ 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
new file mode 100644
index 000000000..36514978f
--- /dev/null
+++ b/internal/cmd/public-ip/update/update_test.go
@@ -0,0 +1,233 @@
+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/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testPublicIpId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testPublicIpId,
+ }
+ 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,
+
+ labelFlag: "key=value",
+ }
+ 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,
+ },
+ PublicIpId: testPublicIpId,
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdatePublicIPRequest)) iaas.ApiUpdatePublicIPRequest {
+ request := testClient.UpdatePublicIP(testCtx, testProjectId, testRegion, testPublicIpId)
+ request = request.UpdatePublicIPPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdatePublicIPPayload)) iaas.UpdatePublicIPPayload {
+ payload := iaas.UpdatePublicIPPayload{
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ }
+ 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: "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: "public ip id invalid 1",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "public ip 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 iaas.ApiUpdatePublicIPRequest
+ }{
+ {
+ 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(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/quota/list/list.go b/internal/cmd/quota/list/list.go
new file mode 100644
index 000000000..d16da37d0
--- /dev/null
+++ b/internal/cmd/quota/list/list.go
@@ -0,0 +1,171 @@
+package list
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ "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/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(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists quotas",
+ Long: "Lists project quotas.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List available quotas`,
+ `$ stackit quota list`,
+ ),
+ ),
+ 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 quotas: %w", err)
+ }
+
+ if items := response.Quotas; items == nil {
+ params.Printer.Info("No quotas found for project %q", projectLabel)
+ } else {
+ if err := outputResult(params.Printer, model.OutputFormat, items); err != nil {
+ return fmt.Errorf("output quotas: %w", err)
+ }
+ }
+
+ 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 *iaas.APIClient) iaas.ApiListQuotasRequest {
+ request := apiClient.ListQuotas(ctx, model.ProjectId, model.Region)
+
+ return request
+}
+
+func outputResult(p *print.Printer, outputFormat string, quotas *iaas.QuotaList) error {
+ if quotas == nil {
+ return fmt.Errorf("quotas is nil")
+ }
+ return p.OutputResult(outputFormat, quotas, func() error {
+ table := tables.NewTable()
+ table.SetHeader("NAME", "LIMIT", "CURRENT USAGE", "PERCENT")
+ if val := quotas.BackupGigabytes; val != nil {
+ table.AddRow("Total size in GiB of backups [GiB]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.Backups; val != nil {
+ table.AddRow("Number of backups [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.Gigabytes; val != nil {
+ table.AddRow("Total size in GiB of volumes and snapshots [GiB]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.Networks; val != nil {
+ table.AddRow("Number of networks [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.Nics; val != nil {
+ table.AddRow("Number of network interfaces (nics) [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.PublicIps; val != nil {
+ table.AddRow("Number of public IP addresses [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.Ram; val != nil {
+ table.AddRow("Amount of server RAM in MiB [MiB]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.SecurityGroupRules; val != nil {
+ table.AddRow("Number of security group rules [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.SecurityGroups; val != nil {
+ table.AddRow("Number of security groups [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.Snapshots; val != nil {
+ table.AddRow("Number of snapshots [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.Vcpu; val != nil {
+ table.AddRow("Number of server cores (vcpu) [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ if val := quotas.Volumes; val != nil {
+ table.AddRow("Number of volumes [Count]", conv(val.Limit), conv(val.Usage), percentage(val))
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
+
+func conv(n *int64) string {
+ if n != nil {
+ return strconv.FormatInt(*n, 10)
+ }
+ return "n/a"
+}
+
+func percentage(val interface {
+ GetLimitOk() (int64, bool)
+ GetUsageOk() (int64, bool)
+}) string {
+ a, aOk := val.GetLimitOk()
+ b, bOk := val.GetUsageOk()
+ if aOk && bOk {
+ return fmt.Sprintf("%3.1f%%", 100.0/float64(a)*float64(b))
+ }
+ return "n/a"
+}
diff --git a/internal/cmd/quota/list/list_test.go b/internal/cmd/quota/list/list_test.go
new file mode 100644
index 000000000..b508a5c94
--- /dev/null
+++ b/internal/cmd/quota/list/list_test.go
@@ -0,0 +1,172 @@
+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/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,
+ }
+ 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 *iaas.ApiListQuotasRequest)) iaas.ApiListQuotasRequest {
+ request := testClient.ListQuotas(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,
+ },
+ }
+
+ 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.ApiListQuotasRequest
+ }{
+ {
+ 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
+ quotas *iaas.QuotaList
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set quota empty",
+ args: args{
+ quotas: &iaas.QuotaList{},
+ },
+ 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.quotas); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/quota/quota.go b/internal/cmd/quota/quota.go
new file mode 100644
index 000000000..ed65097d2
--- /dev/null
+++ b/internal/cmd/quota/quota.go
@@ -0,0 +1,29 @@
+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/types"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "quota",
+ Short: "Manage server quotas",
+ Long: "Manage the lifecycle of server quotas.",
+ 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/rabbitmq/credentials/create/create.go b/internal/cmd/rabbitmq/credentials/create/create.go
index bed0dafd2..3aeea6efe 100644
--- a/internal/cmd/rabbitmq/credentials/create/create.go
+++ b/internal/cmd/rabbitmq/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/spf13/cobra"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
@@ -30,7 +30,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",
@@ -46,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +76,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 +91,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 +103,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
}
@@ -122,42 +112,39 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.AP
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *rabbitmq.CredentialsResponse) error {
- if !model.ShowPassword {
- resp.Raw.Credentials.Password = utils.Ptr("hidden")
+func outputResult(p *print.Printer, model inputModel, instanceLabel string, resp *rabbitmq.CredentialsResponse) error {
+ if model.GlobalFlagModel == nil {
+ return fmt.Errorf("no global flags available")
+ }
+ if resp == nil {
+ return fmt.Errorf("no response available")
}
- 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))
- if err != nil {
- return fmt.Errorf("marshal RabbitMQ credentials: %w", err)
+ if !model.ShowPassword {
+ if resp.Raw == nil {
+ resp.Raw = &rabbitmq.RawCredentials{Credentials: &rabbitmq.Credentials{}}
+ } else if resp.Raw.Credentials == nil {
+ resp.Raw.Credentials = &rabbitmq.Credentials{}
}
- p.Outputln(string(details))
+ resp.Raw.Credentials.Password = utils.Ptr("hidden")
+ }
- return nil
- default:
- p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, *resp.Id)
+ 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
- username := *resp.Raw.Credentials.Username
- if username != "" {
- p.Outputf("Username: %s\n", *resp.Raw.Credentials.Username)
- }
- if !model.ShowPassword {
- p.Outputf("Password: \n")
- } else {
- p.Outputf("Password: %s\n", *resp.Raw.Credentials.Password)
+ if resp.HasRaw() && resp.Raw.Credentials != nil {
+ if username := resp.Raw.Credentials.Username; username != nil && *username != "" {
+ p.Outputf("Username: %s\n", *username)
+ }
+ if !model.ShowPassword {
+ p.Outputf("Password: \n")
+ } else {
+ p.Outputf("Password: %s\n", utils.PtrString(resp.Raw.Credentials.Password))
+ }
+ p.Outputf("Host: %s\n", utils.PtrString(resp.Raw.Credentials.Host))
+ p.Outputf("Port: %s\n", utils.PtrString(resp.Raw.Credentials.Port))
}
- p.Outputf("Host: %s\n", *resp.Raw.Credentials.Host)
- p.Outputf("Port: %d\n", *resp.Raw.Credentials.Port)
- p.Outputf("URI: %s\n", *resp.Uri)
+ 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 494647b00..3286d4931 100644
--- a/internal/cmd/rabbitmq/credentials/create/create_test.go
+++ b/internal/cmd/rabbitmq/credentials/create/create_test.go
@@ -4,17 +4,17 @@ 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/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/testutils"
"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 +24,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 +58,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
@@ -86,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,
},
@@ -129,46 +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)
- err := globalflags.Configure(cmd.Flags())
- if err != nil {
- t.Fatalf("configure global flags: %v", err)
- }
-
- for flag, value := range tt.flagValues {
- err := 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)
})
}
}
@@ -200,3 +162,56 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ model inputModel
+ instanceLabel string
+ resp *rabbitmq.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ model: inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ instanceLabel: "",
+ resp: &rabbitmq.CredentialsResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "nil response",
+ args: args{
+ model: inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ instanceLabel: "",
+ },
+ wantErr: true,
+ },
+ {
+ name: "no flags",
+ args: args{
+ model: inputModel{},
+ instanceLabel: "",
+ resp: &rabbitmq.CredentialsResponse{},
+ },
+ 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.model, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 175488d16..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
}
@@ -112,41 +104,29 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.AP
}
func outputResult(p *print.Printer, outputFormat string, credentials *rabbitmq.CredentialsResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal RabbitMQ credentials: %w", err)
- }
- p.Outputln(string(details))
+ if credentials == nil {
+ return fmt.Errorf("no response passed")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, credentials, func() error {
table := tables.NewTable()
- table.AddRow("ID", *credentials.Id)
+ table.AddRow("ID", utils.PtrString(credentials.Id))
table.AddSeparator()
// The username field cannot be set by the user so we only display it if it's not returned empty
- username := *credentials.Raw.Credentials.Username
- if username != "" {
- table.AddRow("USERNAME", *credentials.Raw.Credentials.Username)
+ if credentials.HasRaw() && credentials.Raw.Credentials != nil {
+ if username := credentials.Raw.Credentials.Username; username != nil && *username != "" {
+ table.AddRow("USERNAME", *username)
+ table.AddSeparator()
+ }
+ table.AddRow("PASSWORD", utils.PtrString(credentials.Raw.Credentials.Password))
table.AddSeparator()
+ table.AddRow("URI", utils.PtrString(credentials.Raw.Credentials.Uri))
}
- table.AddRow("PASSWORD", *credentials.Raw.Credentials.Password)
- table.AddSeparator()
- table.AddRow("URI", *credentials.Raw.Credentials.Uri)
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/rabbitmq/credentials/describe/describe_test.go b/internal/cmd/rabbitmq/credentials/describe/describe_test.go
index ac88bb829..b92353fb9 100644
--- a/internal/cmd/rabbitmq/credentials/describe/describe_test.go
+++ b/internal/cmd/rabbitmq/credentials/describe/describe_test.go
@@ -4,17 +4,17 @@ 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/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/testutils"
"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 +35,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 +104,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 +112,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 +120,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 +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)
})
}
}
@@ -243,3 +196,40 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials *rabbitmq.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ outputFormat: "",
+ credentials: &rabbitmq.CredentialsResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty",
+ args: args{
+ outputFormat: "",
+ },
+ 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.credentials); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/rabbitmq/credentials/list/list.go b/internal/cmd/rabbitmq/credentials/list/list.go
index 54140ec86..218b4a97f 100644
--- a/internal/cmd/rabbitmq/credentials/list/list.go
+++ b/internal/cmd/rabbitmq/credentials/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ 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/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/rabbitmq"
)
@@ -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' IDs for a RabbitMQ instance",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +67,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 +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,30 +124,18 @@ 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))
- 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 {
c := credentials[i]
- table.AddRow(*c.Id)
+ table.AddRow(utils.PtrString(c.Id))
}
err := table.Display(p)
if err != nil {
@@ -165,5 +143,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 71263bdc0..d2593b39b 100644
--- a/internal/cmd/rabbitmq/credentials/list/list_test.go
+++ b/internal/cmd/rabbitmq/credentials/list/list_test.go
@@ -4,18 +4,18 @@ 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-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")
@@ -25,9 +25,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 +61,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
@@ -79,21 +80,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 +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.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,3 +169,41 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ credentials []rabbitmq.CredentialsListItem
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "non empty list with empty elements",
+ args: args{
+ outputFormat: "",
+ credentials: []rabbitmq.CredentialsListItem{
+ {},
+ },
+ },
+ 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.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 464f587b8..1ac8e7cc2 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",
@@ -76,29 +76,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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,7 +116,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating instance")
_, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -127,7 +125,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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
}
@@ -257,29 +247,22 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient rabbitMQClie
}
func outputResult(p *print.Printer, model *inputModel, projectLabel, instanceId string, resp *rabbitmq.CreateInstanceResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal RabbitMQ instance: %w", err)
- }
- p.Outputln(string(details))
+ if model == nil {
+ return fmt.Errorf("no model passed")
+ }
+ if model.GlobalFlagModel == nil {
+ return fmt.Errorf("no globalflags passed")
+ }
+ if resp == nil {
+ return fmt.Errorf("no response passed")
+ }
- 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 0f3b165c6..8a267ca2a 100644
--- a/internal/cmd/rabbitmq/instance/create/create_test.go
+++ b/internal/cmd/rabbitmq/instance/create/create_test.go
@@ -5,18 +5,19 @@ import (
"fmt"
"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/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-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")
@@ -44,17 +45,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 +111,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
@@ -145,9 +147,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 +164,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 +189,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 +278,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)
})
}
}
@@ -488,3 +425,46 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ model inputModel
+ projectLabel string
+ instanceId string
+ resp *rabbitmq.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ model: inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ resp: &rabbitmq.CreateInstanceResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty",
+ args: args{
+ model: inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ },
+ 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.model, tt.args.projectLabel, tt.args.instanceId, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/rabbitmq/instance/delete/delete.go b/internal/cmd/rabbitmq/instance/delete/delete.go
index 66a7b578e..766ced827 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,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
_, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
if err != nil {
@@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 4aeb8fdf8..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
}
@@ -100,41 +92,32 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *rabbitmq.AP
}
func outputResult(p *print.Printer, outputFormat string, instance *rabbitmq.Instance) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal RabbitMQ instance: %w", err)
- }
- p.Outputln(string(details))
+ if instance == nil {
+ return fmt.Errorf("no instance passed")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, instance, func() error {
table := tables.NewTable()
- table.AddRow("ID", *instance.InstanceId)
+ table.AddRow("ID", utils.PtrString(instance.InstanceId))
table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("LAST OPERATION TYPE", *instance.LastOperation.Type)
- table.AddSeparator()
- table.AddRow("LAST OPERATION STATE", *instance.LastOperation.State)
- table.AddSeparator()
- table.AddRow("PLAN ID", *instance.PlanId)
+ if lastOperation := instance.LastOperation; lastOperation != nil {
+ table.AddRow("LAST OPERATION TYPE", utils.PtrString(lastOperation.Type))
+ table.AddSeparator()
+ table.AddRow("LAST OPERATION STATE", utils.PtrString(lastOperation.State))
+ table.AddSeparator()
+ }
+ table.AddRow("PLAN ID", utils.PtrString(instance.PlanId))
// Only show ACL if it's present and not empty
- acl := (*instance.Parameters)[aclParameterKey]
- aclStr, ok := acl.(string)
- if ok {
- if aclStr != "" {
- table.AddSeparator()
- table.AddRow("ACL", aclStr)
+ if parameters := instance.Parameters; parameters != nil {
+ acl := (*instance.Parameters)[aclParameterKey]
+ aclStr, ok := acl.(string)
+ if ok {
+ if aclStr != "" {
+ table.AddSeparator()
+ table.AddRow("ACL", aclStr)
+ }
}
}
err := table.Display(p)
@@ -143,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 ff09ef0ae..c92834466 100644
--- a/internal/cmd/rabbitmq/instance/describe/describe_test.go
+++ b/internal/cmd/rabbitmq/instance/describe/describe_test.go
@@ -4,17 +4,17 @@ 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/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/testutils"
"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 +34,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 +101,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 +109,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 +117,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 +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)
})
}
}
@@ -216,3 +169,47 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *rabbitmq.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{instance: &rabbitmq.Instance{}},
+ wantErr: false,
+ },
+ {
+ name: "nil instance",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty parameters",
+ args: args{
+ outputFormat: "",
+ instance: &rabbitmq.Instance{
+ Parameters: &map[string]interface{}{
+ "foo": 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.outputFormat, tt.args.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 b09ebd083..715f6fa45 100644
--- a/internal/cmd/rabbitmq/instance/list/list.go
+++ b/internal/cmd/rabbitmq/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/rabbitmq/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/rabbitmq"
)
@@ -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 RabbitMQ instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +65,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 +78,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 +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,15 +109,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,30 +118,32 @@ 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)
+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
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(instances, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal RabbitMQ instance list: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
table := tables.NewTable()
table.SetHeader("ID", "NAME", "LAST OPERATION TYPE", "LAST OPERATION STATE")
for i := range instances {
instance := instances[i]
- table.AddRow(*instance.InstanceId, *instance.Name, *instance.LastOperation.Type, *instance.LastOperation.State)
+ var (
+ opType, opState string
+ )
+ if lastOperation := instance.LastOperation; lastOperation != nil {
+ opType = utils.PtrString(lastOperation.Type)
+ opState = utils.PtrString(lastOperation.State)
+ } else {
+ opType, opState = "n/a", "n/a"
+ }
+ table.AddRow(
+ utils.PtrString(instance.InstanceId),
+ utils.PtrString(instance.Name),
+ opType,
+ opState,
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,5 +151,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 004e9f436..18bfa1817 100644
--- a/internal/cmd/rabbitmq/instance/list/list_test.go
+++ b/internal/cmd/rabbitmq/instance/list/list_test.go
@@ -4,19 +4,18 @@ 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/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/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")
@@ -25,8 +24,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 +58,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
@@ -77,21 +77,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 +113,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)
})
}
}
@@ -186,3 +145,41 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ instances []rabbitmq.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "slice with empty element",
+ args: args{
+ outputFormat: "",
+ instances: []rabbitmq.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/rabbitmq/instance/update/update.go b/internal/cmd/rabbitmq/instance/update/update.go
index 67144235d..d3cf53603 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,7 +114,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Updating instance")
_, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -127,7 +127,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 ad19ecf18..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"
@@ -15,6 +15,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"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/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/rabbitmq"
@@ -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 RabbitMQ service plans",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 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,34 +119,30 @@ 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)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(plans, yaml.IndentSequence(true))
- 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
- default:
table := tables.NewTable()
table.SetHeader("OFFERING NAME", "VERSION", "ID", "NAME", "DESCRIPTION")
for i := range plans {
o := plans[i]
- for j := range *o.Plans {
- plan := (*o.Plans)[j]
- table.AddRow(*o.Name, *o.Version, *plan.Id, *plan.Name, *plan.Description)
+ if o.Plans != nil {
+ for j := range *o.Plans {
+ plan := (*o.Plans)[j]
+ table.AddRow(
+ utils.PtrString(o.Name),
+ utils.PtrString(o.Version),
+ utils.PtrString(plan.Id),
+ utils.PtrString(plan.Name),
+ utils.PtrString(plan.Description),
+ )
+ }
+ table.AddSeparator()
}
- table.AddSeparator()
}
table.EnableAutoMergeOnColumns(1, 2)
err := table.Display(p)
@@ -165,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 8fa1378d7..f98191e88 100644
--- a/internal/cmd/rabbitmq/plans/plans_test.go
+++ b/internal/cmd/rabbitmq/plans/plans_test.go
@@ -4,19 +4,18 @@ 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/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/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")
@@ -25,8 +24,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 +58,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
@@ -77,21 +77,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 +113,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)
})
}
}
@@ -186,3 +145,41 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ plans []rabbitmq.Offering
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "slice with empty element",
+ args: args{
+ outputFormat: "",
+ plans: []rabbitmq.Offering{
+ {},
+ },
+ },
+ 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.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 7906c2885..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",
@@ -47,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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
}
@@ -123,43 +113,39 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APICl
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *redis.CredentialsResponse) error {
- if !model.ShowPassword {
- resp.Raw.Credentials.Password = utils.Ptr("hidden")
+func outputResult(p *print.Printer, model inputModel, instanceLabel string, resp *redis.CredentialsResponse) error {
+ if model.GlobalFlagModel == nil {
+ return fmt.Errorf("no global flags defined")
}
-
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- if err != nil {
- return fmt.Errorf("marshal Redis credentials: %w", err)
+ if resp == nil {
+ return fmt.Errorf("no response defined")
+ }
+ if !model.ShowPassword {
+ if resp.Raw == nil {
+ resp.Raw = &redis.RawCredentials{}
}
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Redis credentials: %w", err)
+ if resp.Raw.Credentials == nil {
+ resp.Raw.Credentials = &redis.Credentials{}
}
- p.Outputln(string(details))
+ resp.Raw.Credentials.Password = utils.Ptr("hidden")
+ }
- return nil
- default:
- p.Outputf("Created credentials for instance %q. Credentials ID: %s\n\n", instanceLabel, *resp.Id)
+ 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
- username := *resp.Raw.Credentials.Username
- if username != "" {
- p.Outputf("Username: %s\n", *resp.Raw.Credentials.Username)
- }
- if !model.ShowPassword {
- p.Outputf("Password: \n")
- } else {
- p.Outputf("Password: %s\n", *resp.Raw.Credentials.Password)
+ if resp.HasRaw() && resp.Raw.Credentials != nil {
+ if username := resp.Raw.Credentials.Username; username != nil && *username != "" {
+ p.Outputf("Username: %s\n", utils.PtrString(username))
+ }
+ if !model.ShowPassword {
+ p.Outputf("Password: \n")
+ } else {
+ p.Outputf("Password: %s\n", utils.PtrString(resp.Raw.Credentials.Password))
+ }
+ p.Outputf("Host: %s\n", utils.PtrString(resp.Raw.Credentials.Host))
+ p.Outputf("Port: %s\n", utils.PtrString(resp.Raw.Credentials.Port))
}
- p.Outputf("Host: %s\n", *resp.Raw.Credentials.Host)
- p.Outputf("Port: %d\n", *resp.Raw.Credentials.Port)
- p.Outputf("URI: %s\n", *resp.Uri)
+ 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 bb1ee454a..79d571121 100644
--- a/internal/cmd/redis/credentials/create/create_test.go
+++ b/internal/cmd/redis/credentials/create/create_test.go
@@ -4,12 +4,14 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/redis"
)
@@ -58,6 +60,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
@@ -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)
})
}
}
@@ -200,3 +164,36 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ model inputModel
+ instanceLabel string
+ resp *redis.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{model: inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}, resp: &redis.CredentialsResponse{}},
+ wantErr: false,
+ },
+ {
+ name: "nil response",
+ args: args{model: inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}}},
+ 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.model, tt.args.instanceLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 48ec11a7c..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
}
@@ -112,41 +104,29 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APICl
}
func outputResult(p *print.Printer, outputFormat string, credentials *redis.CredentialsResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal Redis credentials: %w", err)
- }
- p.Outputln(string(details))
+ if credentials == nil {
+ return fmt.Errorf("no credentials passed")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, credentials, func() error {
table := tables.NewTable()
- table.AddRow("ID", *credentials.Id)
+ table.AddRow("ID", utils.PtrString(credentials.Id))
table.AddSeparator()
// The username field cannot be set by the user so we only display it if it's not returned empty
- username := *credentials.Raw.Credentials.Username
- if username != "" {
- table.AddRow("USERNAME", *credentials.Raw.Credentials.Username)
+ if credentials.HasRaw() && credentials.Raw.Credentials != nil {
+ if username := credentials.Raw.Credentials.Username; username != nil && *username != "" {
+ table.AddRow("USERNAME", *username)
+ table.AddSeparator()
+ }
+ table.AddRow("PASSWORD", utils.PtrString(credentials.Raw.Credentials.Password))
table.AddSeparator()
+ table.AddRow("URI", utils.PtrString(credentials.Raw.Credentials.Uri))
}
- table.AddRow("PASSWORD", *credentials.Raw.Credentials.Password)
- table.AddSeparator()
- table.AddRow("URI", *credentials.Raw.Credentials.Uri)
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/redis/credentials/describe/describe_test.go b/internal/cmd/redis/credentials/describe/describe_test.go
index 909d337f4..7ee94e50c 100644
--- a/internal/cmd/redis/credentials/describe/describe_test.go
+++ b/internal/cmd/redis/credentials/describe/describe_test.go
@@ -4,12 +4,14 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/redis"
)
@@ -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)
})
}
}
@@ -243,3 +198,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials *redis.CredentialsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ credentials: &redis.CredentialsResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "nil response",
+ 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.credentials); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/redis/credentials/list/list.go b/internal/cmd/redis/credentials/list/list.go
index b701a03d8..332ef35e0 100644
--- a/internal/cmd/redis/credentials/list/list.go
+++ b/internal/cmd/redis/credentials/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/client"
redisUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/redis"
)
@@ -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' IDs for a Redis instance",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +68,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 +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{}
@@ -117,15 +117,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,30 +126,18 @@ 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))
- 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 {
c := credentials[i]
- table.AddRow(*c.Id)
+ table.AddRow(utils.PtrString(c.Id))
}
err := table.Display(p)
if err != nil {
@@ -165,5 +145,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 d59fc9c80..fdf207e55 100644
--- a/internal/cmd/redis/credentials/list/list_test.go
+++ b/internal/cmd/redis/credentials/list/list_test.go
@@ -4,13 +4,15 @@ 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-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"
)
@@ -61,6 +63,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
@@ -136,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)
})
}
}
@@ -207,3 +171,40 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ credentials []redis.CredentialsListItem
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "slice with empty element",
+ args: args{
+ outputFormat: "",
+ credentials: []redis.CredentialsListItem{
+ {},
+ },
+ },
+ 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-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 cd71a84a4..8993b98ca 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",
@@ -74,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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,7 +114,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Creating instance")
_, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -125,7 +123,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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
}
@@ -252,29 +242,22 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient redisClient)
}
func outputResult(p *print.Printer, model *inputModel, projectLabel, instanceId string, resp *redis.CreateInstanceResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal Redis instance: %w", err)
- }
- p.Outputln(string(details))
+ if model == nil {
+ return fmt.Errorf("no model passed")
+ }
+ if model.GlobalFlagModel == nil {
+ return fmt.Errorf("no global flags passed")
+ }
+ if resp == nil {
+ return fmt.Errorf("no response defined")
+ }
- 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 dd35d51be..cc6baaeed 100644
--- a/internal/cmd/redis/instance/create/create_test.go
+++ b/internal/cmd/redis/instance/create/create_test.go
@@ -5,13 +5,15 @@ import (
"fmt"
"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-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"
)
@@ -107,6 +109,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
@@ -261,66 +264,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)
})
}
}
@@ -463,3 +410,46 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ projectLabel string
+ instanceId string
+ resp *redis.CreateInstanceResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}},
+ projectLabel: "",
+ instanceId: testMonitoringInstanceId,
+ resp: &redis.CreateInstanceResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "nil response",
+ args: args{
+ model: &inputModel{GlobalFlagModel: &globalflags.GlobalFlagModel{}},
+ projectLabel: "",
+ instanceId: testMonitoringInstanceId,
+ },
+ 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.model, tt.args.projectLabel, tt.args.instanceId, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/redis/instance/delete/delete.go b/internal/cmd/redis/instance/delete/delete.go
index 902e30ada..0c8124906 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,7 +75,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting instance")
_, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx)
if err != nil {
@@ -88,7 +88,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 b89ac51f1..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
}
@@ -100,41 +92,32 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *redis.APICl
}
func outputResult(p *print.Printer, outputFormat string, instance *redis.Instance) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal Redis instance: %w", err)
- }
- p.Outputln(string(details))
+ if instance == nil {
+ return fmt.Errorf("no instance passed")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, instance, func() error {
table := tables.NewTable()
- table.AddRow("ID", *instance.InstanceId)
+ table.AddRow("ID", utils.PtrString(instance.InstanceId))
table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("LAST OPERATION TYPE", *instance.LastOperation.Type)
- table.AddSeparator()
- table.AddRow("LAST OPERATION STATE", *instance.LastOperation.State)
- table.AddSeparator()
- table.AddRow("PLAN ID", *instance.PlanId)
- // Only show ACL if it's present and not empty
- acl := (*instance.Parameters)[aclParameterKey]
- aclStr, ok := acl.(string)
- if ok {
- if aclStr != "" {
- table.AddSeparator()
- table.AddRow("ACL", aclStr)
+ if lastOperation := instance.LastOperation; lastOperation != nil {
+ table.AddRow("LAST OPERATION TYPE", utils.PtrString(instance.LastOperation.Type))
+ table.AddSeparator()
+ table.AddRow("LAST OPERATION STATE", utils.PtrString(instance.LastOperation.State))
+ table.AddSeparator()
+ }
+ table.AddRow("PLAN ID", utils.PtrString(instance.PlanId))
+ if parameters := instance.Parameters; parameters != nil {
+ // Only show ACL if it's present and not empty
+ acl := (*parameters)[aclParameterKey]
+ aclStr, ok := acl.(string)
+ if ok {
+ if aclStr != "" {
+ table.AddSeparator()
+ table.AddRow("ACL", aclStr)
+ }
}
}
err := table.Display(p)
@@ -143,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 188927f28..16a99ab65 100644
--- a/internal/cmd/redis/instance/describe/describe_test.go
+++ b/internal/cmd/redis/instance/describe/describe_test.go
@@ -4,12 +4,14 @@ 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/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/testutils"
"github.com/stackitcloud/stackit-sdk-go/services/redis"
)
@@ -137,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)
})
}
}
@@ -216,3 +171,48 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *redis.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{
+ instance: &redis.Instance{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "nil response",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "nil parameter",
+ args: args{
+ instance: &redis.Instance{
+ Parameters: &map[string]interface{}{
+ "foo": 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.outputFormat, tt.args.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 969b20058..2051f471e 100644
--- a/internal/cmd/redis/instance/list/list.go
+++ b/internal/cmd/redis/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/redis"
)
@@ -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 Redis instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +66,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 +92,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 +111,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,30 +120,29 @@ 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))
- 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 {
instance := instances[i]
- table.AddRow(*instance.InstanceId, *instance.Name, *instance.LastOperation.Type, *instance.LastOperation.State)
+ var (
+ lastOperationType, lastOperationState string
+ )
+ if lastOperation := instance.LastOperation; lastOperation != nil {
+ lastOperationType, lastOperationState = utils.PtrString(lastOperation.Type), utils.PtrString(lastOperation.State)
+ }
+ table.AddRow(
+ utils.PtrString(instance.InstanceId),
+ utils.PtrString(instance.Name),
+ lastOperationType,
+ lastOperationState,
+ )
}
err := table.Display(p)
if err != nil {
@@ -160,5 +150,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 692c71222..81053ecdd 100644
--- a/internal/cmd/redis/instance/list/list_test.go
+++ b/internal/cmd/redis/instance/list/list_test.go
@@ -4,14 +4,15 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/redis"
)
@@ -59,6 +60,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
@@ -113,48 +115,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)
})
}
}
@@ -186,3 +147,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instances []redis.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "slice with empty element",
+ args: args{
+ instances: []redis.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, "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..3fcc2e7b4 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,7 +111,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Updating instance")
_, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx)
if err != nil {
@@ -124,7 +124,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 292857121..f7ecfe5cf 100644
--- a/internal/cmd/redis/plans/plans.go
+++ b/internal/cmd/redis/plans/plans.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/redis/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/redis"
)
@@ -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: "plans",
Short: "Lists all Redis service plans",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +67,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 +81,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 +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: 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,33 +122,24 @@ 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))
- 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 {
o := plans[i]
- for j := range *o.Plans {
- plan := (*o.Plans)[j]
- table.AddRow(*o.Name, *o.Version, *plan.Id, *plan.Name, *plan.Description)
+ if o.Plans != nil {
+ for j := range *o.Plans {
+ plan := (*o.Plans)[j]
+ table.AddRow(
+ utils.PtrString(o.Name),
+ utils.PtrString(o.Version),
+ utils.PtrString(plan.Id),
+ utils.PtrString(plan.Name),
+ utils.PtrString(plan.Description),
+ )
+ }
+ table.AddSeparator()
}
- table.AddSeparator()
}
table.EnableAutoMergeOnColumns(1, 2)
err := table.Display(p)
@@ -165,5 +148,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 7f1a7a909..a18048cd6 100644
--- a/internal/cmd/redis/plans/plans_test.go
+++ b/internal/cmd/redis/plans/plans_test.go
@@ -4,14 +4,15 @@ 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/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/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/redis"
)
@@ -59,6 +60,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
@@ -113,48 +115,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)
})
}
}
@@ -186,3 +147,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ plans []redis.Offering
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "slice with empty elements",
+ args: args{
+ plans: []redis.Offering{
+ {},
+ },
+ },
+ 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.plans); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 97384911f..ccb1b9f5a 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -6,26 +6,40 @@ import (
"strings"
"time"
- "github.com/stackitcloud/stackit-cli/internal/cmd/argus"
+ "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"
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/mariadb"
"github.com/stackitcloud/stackit-cli/internal/cmd/mongodbflex"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/network"
+ networkArea "github.com/stackitcloud/stackit-cli/internal/cmd/network-area"
+ networkinterface "github.com/stackitcloud/stackit-cli/internal/cmd/network-interface"
objectstorage "github.com/stackitcloud/stackit-cli/internal/cmd/object-storage"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/observability"
"github.com/stackitcloud/stackit-cli/internal/cmd/opensearch"
"github.com/stackitcloud/stackit-cli/internal/cmd/organization"
"github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex"
"github.com/stackitcloud/stackit-cli/internal/cmd/project"
+ publicip "github.com/stackitcloud/stackit-cli/internal/cmd/public-ip"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/quota"
"github.com/stackitcloud/stackit-cli/internal/cmd/rabbitmq"
"github.com/stackitcloud/stackit-cli/internal/cmd/redis"
secretsmanager "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager"
+ securitygroup "github.com/stackitcloud/stackit-cli/internal/cmd/security-group"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server"
serviceaccount "github.com/stackitcloud/stackit-cli/internal/cmd/service-account"
"github.com/stackitcloud/stackit-cli/internal/cmd/ske"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/volume"
"github.com/stackitcloud/stackit-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
@@ -41,14 +55,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, args []string) error {
+ 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)
@@ -82,9 +98,9 @@ func NewRootCmd(version, date string, p *print.Printer) *cobra.Command {
return nil
},
- RunE: func(cmd *cobra.Command, args []string) error {
+ 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 {
@@ -103,7 +119,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
@@ -143,27 +162,39 @@ func configureFlags(cmd *cobra.Command) error {
return nil
}
-func addSubcommands(cmd *cobra.Command, p *print.Printer) {
- cmd.AddCommand(argus.NewCmd(p))
- 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(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))
+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(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))
}
// traverseCommands calls f for c and all of its children.
@@ -180,6 +211,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
@@ -187,7 +219,7 @@ func Execute(version, date string) {
if err != nil {
err := beautifyUnknownAndMissingCommandsError(cmd, err)
p.Debug(print.ErrorLevel, "execute command: %v", err)
- p.Error(err.Error())
+ p.Error("%s", err.Error())
os.Exit(1)
}
}
diff --git a/internal/cmd/secrets-manager/instance/create/create.go b/internal/cmd/secrets-manager/instance/create/create.go
index e574e35c8..0c6d8420b 100644
--- a/internal/cmd/secrets-manager/instance/create/create.go
+++ b/internal/cmd/secrets-manager/instance/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"
@@ -32,7 +32,7 @@ type inputModel struct {
Acls *[]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",
@@ -49,29 +49,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
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 +92,7 @@ If you want to retry configuring the ACLs, you can do it via:
}
}
- return outputResult(p, model, projectLabel, instanceId, resp)
+ return outputResult(params.Printer, model.OutputFormat, projectLabel, instanceId, resp)
},
}
configureFlags(cmd)
@@ -109,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, &cliErr.ProjectIdError{}
@@ -121,15 +119,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) {
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)
- }
- }
-
+ p.DebugInputModel(model)
return &model, nil
}
@@ -157,26 +147,13 @@ func buildUpdateACLsRequest(ctx context.Context, model *inputModel, instanceId s
return req
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel, instanceId string, resp *secretsmanager.Instance) error {
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- 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(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Secrets Manager instance: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, projectLabel, instanceId string, instance *secretsmanager.Instance) error {
+ if instance == nil {
+ return fmt.Errorf("instance is nil")
+ }
- 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 96dc7d994..4cef0d887 100644
--- a/internal/cmd/secrets-manager/instance/create/create_test.go
+++ b/internal/cmd/secrets-manager/instance/create/create_test.go
@@ -4,11 +4,14 @@ 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-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"
)
@@ -76,6 +79,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
@@ -183,56 +187,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)
})
}
}
@@ -302,3 +259,39 @@ func TestBuildCreateACLRequests(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ instanceId string
+ instance *secretsmanager.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty instance",
+ args: args{
+ instance: &secretsmanager.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.instanceId, tt.args.instance); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/secrets-manager/instance/delete/delete.go b/internal/cmd/secrets-manager/instance/delete/delete.go
index 960034165..94ed3385a 100644
--- a/internal/cmd/secrets-manager/instance/delete/delete.go
+++ b/internal/cmd/secrets-manager/instance/delete/delete.go
@@ -4,6 +4,8 @@ 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"
@@ -25,7 +27,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 +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
}
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 +70,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 +90,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 e299138de..bbd162bff 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
}
@@ -111,44 +103,33 @@ func buildListACLsRequest(ctx context.Context, model *inputModel, apiClient *sec
}
func outputResult(p *print.Printer, outputFormat string, instance *secretsmanager.Instance, aclList *secretsmanager.ListACLsResponse) error {
+ if instance == nil {
+ return fmt.Errorf("instance is nil")
+ } else if aclList == nil {
+ return fmt.Errorf("aclList is nil")
+ }
+
output := struct {
*secretsmanager.Instance
*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))
- 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", *instance.Id)
+ table.AddRow("ID", utils.PtrString(instance.Id))
table.AddSeparator()
- table.AddRow("NAME", *instance.Name)
+ table.AddRow("NAME", utils.PtrString(instance.Name))
table.AddSeparator()
- table.AddRow("STATE", *instance.State)
+ table.AddRow("STATE", utils.PtrString(instance.State))
table.AddSeparator()
- table.AddRow("SECRETS", *instance.SecretCount)
+ table.AddRow("SECRETS", utils.PtrString(instance.SecretCount))
table.AddSeparator()
- table.AddRow("ENGINE", *instance.SecretsEngine)
+ table.AddRow("ENGINE", utils.PtrString(instance.SecretsEngine))
table.AddSeparator()
- table.AddRow("CREATION DATE", *instance.CreationStartDate)
+ table.AddRow("CREATION DATE", utils.PtrString(instance.CreationStartDate))
table.AddSeparator()
// Only show ACL if it's present and not empty
- if aclList != nil && aclList.Acls != nil && len(*aclList.Acls) > 0 {
+ if aclList.Acls != nil && len(*aclList.Acls) > 0 {
var cidrs []string
for _, acl := range *aclList.Acls {
@@ -163,5 +144,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 a6026be25..c1e3e0bb7 100644
--- a/internal/cmd/secrets-manager/instance/describe/describe_test.go
+++ b/internal/cmd/secrets-manager/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"
@@ -145,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)
})
}
}
@@ -252,3 +208,53 @@ func TestBuildGetACLsRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instance *secretsmanager.Instance
+ aclList *secretsmanager.ListACLsResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "missing acl",
+ args: args{
+ aclList: &secretsmanager.ListACLsResponse{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing instance",
+ args: args{
+ instance: &secretsmanager.Instance{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty instance and empty acl",
+ args: args{
+ instance: &secretsmanager.Instance{},
+ aclList: &secretsmanager.ListACLsResponse{},
+ },
+ 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.instance, tt.args.aclList); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 9aa3a6a33..e32f99ed0 100644
--- a/internal/cmd/secrets-manager/instance/list/list.go
+++ b/internal/cmd/secrets-manager/instance/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,9 +16,8 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/secretsmanager"
-
- "github.com/spf13/cobra"
)
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 Secrets Manager instances",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +67,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 +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)
},
}
@@ -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,29 +123,17 @@ 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))
- 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 {
instance := instances[i]
- table.AddRow(*instance.Id, *instance.Name, *instance.State, *instance.SecretCount)
+ table.AddRow(
+ utils.PtrString(instance.Id),
+ utils.PtrString(instance.Name),
+ utils.PtrString(instance.State),
+ utils.PtrString(instance.SecretCount),
+ )
}
err := table.Display(p)
if err != nil {
@@ -161,5 +141,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 3de6c45fa..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)
})
}
}
@@ -196,3 +158,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instances []secretsmanager.Instance
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty instances slice",
+ args: args{
+ instances: []secretsmanager.Instance{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty instance in instances slice",
+ args: args{
+ instances: []secretsmanager.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.instances); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/secrets-manager/instance/update/update.go b/internal/cmd/secrets-manager/instance/update/update.go
index e013c1e73..58786ffd6 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"
@@ -32,7 +34,7 @@ type inputModel struct {
Acls *[]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",
@@ -45,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
}
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 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
@@ -77,7 +77,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("update Secrets Manager instance: %w", err)
}
- p.Info("Updated instance %q\n", instanceLabel)
+ params.Printer.Info("Updated instance %q\n", instanceLabel)
return nil
},
}
@@ -109,15 +109,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
Acls: acls,
}
- if p.IsVerbosityDebug() {
- modelStr, err := print.BuildDebugStrFromInputModel(model)
- if err != nil {
- p.Debug(print.ErrorLevel, "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/update/update_test.go b/internal/cmd/secrets-manager/instance/update/update_test.go
index 8668d9ca1..24e14d1fb 100644
--- a/internal/cmd/secrets-manager/instance/update/update_test.go
+++ b/internal/cmd/secrets-manager/instance/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"
@@ -198,7 +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)
+ 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/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 ac919e456..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",
@@ -54,29 +54,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
Args: args.NoArgs,
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, 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
}
@@ -137,31 +127,18 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmana
return req
}
-func outputResult(p *print.Printer, model *inputModel, instanceLabel string, resp *secretsmanager.User) error {
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- 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(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal Secrets Manager user: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, instanceLabel string, user *secretsmanager.User) error {
+ if user == nil {
+ return fmt.Errorf("user is nil")
+ }
- return nil
- default:
- p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, *resp.Id)
- p.Outputf("Username: %s\n", *resp.Username)
- p.Outputf("Password: %s\n", *resp.Password)
- p.Outputf("Description: %s\n", *resp.Description)
- p.Outputf("Write Access: %t\n", *resp.Write)
+ 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))
+ p.Outputf("Description: %s\n", utils.PtrString(user.Description))
+ 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 701787089..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)
})
}
}
@@ -225,3 +187,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ instanceLabel string
+ user *secretsmanager.User
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty user",
+ args: args{
+ user: &secretsmanager.User{},
+ },
+ 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.instanceLabel, tt.args.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 35b8f927f..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,28 +106,11 @@ 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))
- 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", *user.Id)
+ table.AddRow("ID", utils.PtrString(user.Id))
table.AddSeparator()
- table.AddRow("USERNAME", *user.Username)
+ table.AddRow("USERNAME", utils.PtrString(user.Username))
table.AddSeparator()
if user.Description != nil && *user.Description != "" {
table.AddRow("DESCRIPTION", *user.Description)
@@ -145,7 +120,7 @@ func outputResult(p *print.Printer, outputFormat string, user secretsmanager.Use
table.AddRow("PASSWORD", *user.Password)
table.AddSeparator()
}
- table.AddRow("WRITE ACCESS", *user.Write)
+ table.AddRow("WRITE ACCESS", utils.PtrString(user.Write))
err := table.Display(p)
if err != nil {
@@ -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 c34fb0063..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)
})
}
}
@@ -243,3 +199,37 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ user secretsmanager.User
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty user",
+ args: args{
+ user: secretsmanager.User{},
+ },
+ 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.user); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/secrets-manager/user/list/list.go b/internal/cmd/secrets-manager/user/list/list.go
index cfc9b1a87..9b601d47a 100644
--- a/internal/cmd/secrets-manager/user/list/list.go
+++ b/internal/cmd/secrets-manager/user/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ 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/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/secretsmanager"
)
@@ -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 Secrets Manager users",
@@ -51,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +71,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 +84,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 +100,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 +120,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,29 +130,17 @@ 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))
- 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 {
user := users[i]
- table.AddRow(*user.Id, *user.Username, *user.Description, *user.Write)
+ table.AddRow(
+ utils.PtrString(user.Id),
+ utils.PtrString(user.Username),
+ utils.PtrString(user.Description),
+ utils.PtrString(user.Write),
+ )
}
err := table.Display(p)
if err != nil {
@@ -168,5 +148,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 9f996c584..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)
})
}
}
@@ -203,3 +165,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ users []secretsmanager.User
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty users slice",
+ args: args{
+ users: []secretsmanager.User{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty user in users slice",
+ args: args{
+ users: []secretsmanager.User{{}},
+ },
+ 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.users); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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..738426858 100644
--- a/internal/cmd/secrets-manager/user/user.go
+++ b/internal/cmd/secrets-manager/user/user.go
@@ -2,7 +2,7 @@ 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"
@@ -13,7 +13,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 +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/security-group/create/create.go b/internal/cmd/security-group/create/create.go
new file mode 100644
index 000000000..5c52d133a
--- /dev/null
+++ b/internal/cmd/security-group/create/create.go
@@ -0,0 +1,134 @@
+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/services/iaas/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ nameFlag = "name"
+ descriptionFlag = "description"
+ statefulFlag = "stateful"
+ labelsFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Labels *map[string]string
+ Description *string
+ Name *string
+ Stateful *bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates security groups",
+ Long: "Creates security groups.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ 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, 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
+ }
+
+ 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
+ request := buildRequest(ctx, model, apiClient)
+
+ group, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("create security group: %w", err)
+ }
+
+ if err := outputResult(params.Printer, model.OutputFormat, *model.Name, *group); err != nil {
+ return err
+ }
+
+ return nil
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(nameFlag, "", "The name of the security group.")
+ cmd.Flags().String(descriptionFlag, "", "An optional description of the security group.")
+ cmd.Flags().Bool(statefulFlag, false, "Create a stateful or a stateless security group")
+ cmd.Flags().StringToString(labelsFlag, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'")
+
+ 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)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: &name,
+
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag),
+ Description: flags.FlagToStringPointer(p, cmd, descriptionFlag),
+ Stateful: flags.FlagToBoolPointer(p, cmd, statefulFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateSecurityGroupRequest {
+ request := apiClient.CreateSecurityGroup(ctx, model.ProjectId, model.Region)
+
+ payload := iaas.CreateSecurityGroupPayload{
+ Description: model.Description,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ Name: model.Name,
+ Stateful: model.Stateful,
+ }
+
+ return request.CreateSecurityGroupPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, name string, resp iaas.SecurityGroup) error {
+ 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
+ })
+}
diff --git a/internal/cmd/security-group/create/create_test.go b/internal/cmd/security-group/create/create_test.go
new file mode 100644
index 000000000..b025e7419
--- /dev/null
+++ b/internal/cmd/security-group/create/create_test.go
@@ -0,0 +1,268 @@
+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-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{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+ testName = "new-security-group"
+ testDescription = "a test description"
+ testLabels = map[string]string{
+ "fooKey": "fooValue",
+ "barKey": "barValue",
+ "bazKey": "bazValue",
+ }
+ testStateful = true
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ descriptionFlag: testDescription,
+ labelsFlag: "fooKey=fooValue,barKey=barValue,bazKey=bazValue",
+ statefulFlag: "true",
+ nameFlag: testName,
+ }
+ 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,
+ },
+ Labels: &testLabels,
+ Description: &testDescription,
+ Name: &testName,
+ Stateful: &testStateful,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func toStringAnyMapPtr(m map[string]string) map[string]any {
+ if m == nil {
+ return nil
+ }
+ result := map[string]any{}
+ for k, v := range m {
+ result[k] = v
+ }
+ return result
+}
+func fixtureRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRequest)) iaas.ApiCreateSecurityGroupRequest {
+ request := testClient.CreateSecurityGroup(testCtx, testProjectId, testRegion)
+
+ request = request.CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{
+ Description: &testDescription,
+ Labels: utils.Ptr(toStringAnyMapPtr(testLabels)),
+ Name: &testName,
+ Rules: nil,
+ Stateful: &testStateful,
+ })
+ 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,
+ },
+ {
+ description: "no labels",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelsFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ },
+ {
+ description: "single label",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelsFlag] = "foo=bar"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = &map[string]string{
+ "foo": "bar",
+ }
+ }),
+ },
+ {
+ description: "stateless security group",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[statefulFlag] = "false"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Stateful = utils.Ptr(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.ApiCreateSecurityGroupRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "no labels",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiCreateSecurityGroupRequest) {
+ *request = (*request).CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{
+ Description: &testDescription,
+ Labels: nil,
+ Name: &testName,
+ Stateful: &testStateful,
+ })
+ }),
+ },
+ {
+ description: "stateless security group",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Stateful = utils.Ptr(false)
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiCreateSecurityGroupRequest) {
+ *request = (*request).CreateSecurityGroupPayload(iaas.CreateSecurityGroupPayload{
+ Description: &testDescription,
+ Labels: utils.Ptr(toStringAnyMapPtr(testLabels)),
+ Name: &testName,
+ Stateful: utils.Ptr(false),
+ })
+ }),
+ },
+ }
+
+ 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 iaas.SecurityGroup
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.name, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/security-group/delete/delete.go b/internal/cmd/security-group/delete/delete.go
new file mode 100644
index 000000000..1950d712b
--- /dev/null
+++ b/internal/cmd/security-group/delete/delete.go
@@ -0,0 +1,102 @@
+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/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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ SecurityGroupId string
+}
+
+const groupIdArg = "GROUP_ID"
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", groupIdArg),
+ Short: "Deletes a security group",
+ Long: "Deletes a security group by its internal ID.",
+ Args: args.SingleArg(groupIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(`Delete a named group with ID "xxx"`, `$ stackit security-group 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
+ }
+
+ groupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId)
+ if err != nil {
+ params.Printer.Warn("get security group name: %v", err)
+ groupLabel = model.SecurityGroupId
+ }
+
+ 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
+ request := buildRequest(ctx, model, apiClient)
+
+ if err := request.Execute(); err != nil {
+ return fmt.Errorf("delete security group: %w", err)
+ }
+ params.Printer.Info("Deleted security group %q for %q\n", groupLabel, projectLabel)
+
+ 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,
+ SecurityGroupId: cliArgs[0],
+ }
+
+ 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.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
new file mode 100644
index 000000000..0416a41fe
--- /dev/null
+++ b/internal/cmd/security-group/delete/delete_test.go
@@ -0,0 +1,193 @@
+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/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+ testGroupId = 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,
+ },
+ SecurityGroupId: testGroupId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteSecurityGroupRequest)) iaas.ApiDeleteSecurityGroupRequest {
+ request := testClient.DeleteSecurityGroup(testCtx, testProjectId, testRegion, testGroupId)
+ 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{testGroupId},
+ 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 group 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 iaas.ApiDeleteSecurityGroupRequest
+ }{
+ {
+ 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/security-group/describe/describe.go b/internal/cmd/security-group/describe/describe.go
new file mode 100644
index 000000000..485c2f10d
--- /dev/null
+++ b/internal/cmd/security-group/describe/describe.go
@@ -0,0 +1,195 @@
+package describe
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ SecurityGroupId string
+}
+
+const groupIdArg = "GROUP_ID"
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", groupIdArg),
+ Short: "Describes security groups",
+ Long: "Describes security groups by its internal ID.",
+ Args: args.SingleArg(groupIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(`Describe group "xxx"`, `$ stackit security-group 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)
+
+ group, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("get security group: %w", err)
+ }
+
+ if err := outputResult(params.Printer, model.OutputFormat, group); err != nil {
+ return err
+ }
+
+ return nil
+ },
+ }
+
+ return cmd
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetSecurityGroupRequest {
+ request := apiClient.GetSecurityGroup(ctx, model.ProjectId, model.Region, model.SecurityGroupId)
+ return request
+}
+
+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,
+ SecurityGroupId: cliArgs[0],
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func outputResult(p *print.Printer, outputFormat string, resp *iaas.SecurityGroup) error {
+ if resp == nil {
+ return fmt.Errorf("security group response is empty")
+ }
+ return p.OutputResult(outputFormat, resp, func() error {
+ var content []tables.Table
+
+ table := tables.NewTable()
+ table.SetTitle("SECURITY GROUP")
+
+ 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 description := resp.Description; description != nil {
+ table.AddRow("DESCRIPTION", *description)
+ table.AddSeparator()
+ }
+
+ if stateful := resp.Stateful; stateful != nil {
+ table.AddRow("STATEFUL", *stateful)
+ table.AddSeparator()
+ }
+
+ if resp.Labels != nil && len(*resp.Labels) > 0 {
+ var labels []string
+ for key, value := range *resp.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ table.AddSeparator()
+ }
+
+ if resp.CreatedAt != nil {
+ table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(resp.CreatedAt))
+ table.AddSeparator()
+ }
+
+ if resp.UpdatedAt != nil {
+ table.AddRow("UPDATED AT", utils.ConvertTimePToDateTimeString(resp.UpdatedAt))
+ table.AddSeparator()
+ }
+
+ content = append(content, table)
+
+ if resp.Rules != nil && len(*resp.Rules) > 0 {
+ rulesTable := tables.NewTable()
+ rulesTable.SetTitle("RULES")
+ rulesTable.SetHeader(
+ "ID",
+ "DESCRIPTION",
+ "PROTOCOL",
+ "DIRECTION",
+ "ETHER TYPE",
+ "PORT RANGE",
+ "IP RANGE",
+ "ICMP PARAMETERS",
+ "REMOTE SECURITY GROUP ID",
+ )
+
+ for _, rule := range *resp.Rules {
+ var portRange string
+ if rule.PortRange != nil {
+ portRange = fmt.Sprintf("%s-%s", utils.PtrString(rule.PortRange.Min), utils.PtrString(rule.PortRange.Max))
+ }
+
+ var protocol string
+ if rule.Protocol != nil {
+ protocol = utils.PtrString(rule.Protocol.Name)
+ }
+
+ var icmpParameter string
+ if rule.IcmpParameters != nil {
+ icmpParameter = fmt.Sprintf("type: %s, code: %s", utils.PtrString(rule.IcmpParameters.Type), utils.PtrString(rule.IcmpParameters.Code))
+ }
+
+ rulesTable.AddRow(
+ utils.PtrString(rule.Id),
+ utils.PtrString(rule.Description),
+ protocol,
+ utils.PtrString(rule.Direction),
+ utils.PtrString(rule.Ethertype),
+ portRange,
+ utils.PtrString(rule.IpRange),
+ icmpParameter,
+ utils.PtrString(rule.RemoteSecurityGroupId),
+ )
+ }
+
+ content = append(content, rulesTable)
+ }
+
+ if err := tables.DisplayTables(p, content); err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/security-group/describe/describe_test.go b/internal/cmd/security-group/describe/describe_test.go
new file mode 100644
index 000000000..9342e946e
--- /dev/null
+++ b/internal/cmd/security-group/describe/describe_test.go
@@ -0,0 +1,237 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "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()
+ testSecurityGroupId = []string{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,
+ },
+ SecurityGroupId: testSecurityGroupId[0],
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetSecurityGroupRequest)) iaas.ApiGetSecurityGroupRequest {
+ request := testClient.GetSecurityGroup(testCtx, testProjectId, testRegion, testSecurityGroupId[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: testSecurityGroupId,
+ isValid: true,
+ },
+ {
+ description: "no values",
+ flagValues: map[string]string{},
+ args: testSecurityGroupId,
+ isValid: false,
+ },
+ {
+ description: "project id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, globalflags.ProjectIdFlag)
+ }),
+ args: testSecurityGroupId,
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[globalflags.ProjectIdFlag] = ""
+ }),
+ args: testSecurityGroupId,
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[globalflags.ProjectIdFlag] = "invalid-uuid"
+ }),
+ args: testSecurityGroupId,
+ isValid: false,
+ },
+ {
+ description: "no group id passed",
+ flagValues: fixtureFlagValues(),
+ args: nil,
+ isValid: false,
+ },
+ {
+ description: "multiple group ids passed",
+ flagValues: fixtureFlagValues(),
+ args: []string{uuid.NewString(), uuid.NewString()},
+ isValid: false,
+ },
+ {
+ description: "invalid group 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 iaas.ApiGetSecurityGroupRequest
+ }{
+ {
+ 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 *iaas.SecurityGroup
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only security group as argument",
+ args: args{
+ resp: &iaas.SecurityGroup{},
+ },
+ 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/security-group/list/list.go b/internal/cmd/security-group/list/list.go
new file mode 100644
index 000000000..a368152a6
--- /dev/null
+++ b/internal/cmd/security-group/list/list.go
@@ -0,0 +1,142 @@
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ LabelSelector *string
+}
+
+const (
+ labelSelectorFlag = "label-selector"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists security groups",
+ Long: "Lists security groups by its internal ID.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ 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, 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
+ }
+
+ // Call API
+ request := buildRequest(ctx, model, apiClient)
+
+ response, err := request.Execute()
+ if err != nil {
+ return fmt.Errorf("list security group: %w", err)
+ }
+
+ if items := response.GetItems(); len(items) == 0 {
+ params.Printer.Info("No security groups found for project %q", projectLabel)
+ } else {
+ if err := outputResult(params.Printer, model.OutputFormat, items); err != nil {
+ return fmt.Errorf("output security groups: %w", err)
+ }
+ }
+
+ return nil
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListSecurityGroupsRequest {
+ request := apiClient.ListSecurityGroups(ctx, model.ProjectId, model.Region)
+ if model.LabelSelector != nil {
+ request = request.LabelSelector(*model.LabelSelector)
+ }
+
+ return request
+}
+func outputResult(p *print.Printer, outputFormat string, items []iaas.SecurityGroup) error {
+ return p.OutputResult(outputFormat, items, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "NAME", "STATEFUL", "DESCRIPTION", "LABELS")
+ for _, item := range items {
+ var labelsString string
+ if item.Labels != nil {
+ var labels []string
+ for key, value := range *item.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ labelsString = strings.Join(labels, ", ")
+ }
+
+ table.AddRow(
+ utils.PtrString(item.Id),
+ utils.PtrString(item.Name),
+ utils.PtrString(item.Stateful),
+ utils.PtrString(item.Description),
+ labelsString,
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/security-group/list/list_test.go b/internal/cmd/security-group/list/list_test.go
new file mode 100644
index 000000000..22c588604
--- /dev/null
+++ b/internal/cmd/security-group/list/list_test.go
@@ -0,0 +1,208 @@
+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-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{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+ testLabels = "fooKey=fooValue,barKey=barValue,bazKey=bazValue"
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ labelSelectorFlag: testLabels,
+ }
+ 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,
+ },
+ LabelSelector: utils.Ptr(testLabels),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListSecurityGroupsRequest)) iaas.ApiListSecurityGroupsRequest {
+ request := testClient.ListSecurityGroups(testCtx, testProjectId, testRegion)
+ request = request.LabelSelector(testLabels)
+ 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: "no labels",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelSelectorFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = nil
+ }),
+ },
+ {
+ description: "single label",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = "foo=bar"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("foo=bar")
+ }),
+ },
+ }
+
+ 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.ApiListSecurityGroupsRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "no labels",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiListSecurityGroupsRequest) {
+ *request = (*request).LabelSelector("")
+ }),
+ },
+ {
+ description: "single label",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("foo=bar")
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiListSecurityGroupsRequest) {
+ *request = (*request).LabelSelector("foo=bar")
+ }),
+ },
+ }
+
+ 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
+ items []iaas.SecurityGroup
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.items); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/security-group/rule/create/create.go b/internal/cmd/security-group/rule/create/create.go
new file mode 100644
index 000000000..b2f443ace
--- /dev/null
+++ b/internal/cmd/security-group/rule/create/create.go
@@ -0,0 +1,223 @@
+package create
+
+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"
+ "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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ securityGroupIdFlag = "security-group-id"
+ directionFlag = "direction"
+ descriptionFlag = "description"
+ etherTypeFlag = "ether-type"
+ icmpParameterCodeFlag = "icmp-parameter-code"
+ icmpParameterTypeFlag = "icmp-parameter-type"
+ ipRangeFlag = "ip-range"
+ portRangeMaxFlag = "port-range-max"
+ portRangeMinFlag = "port-range-min"
+ remoteSecurityGroupIdFlag = "remote-security-group-id"
+ protocolNumberFlag = "protocol-number"
+ protocolNameFlag = "protocol-name"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ SecurityGroupId string
+ Direction *string
+ Description *string
+ EtherType *string
+ IcmpParameterCode *int64
+ IcmpParameterType *int64
+ IpRange *string
+ PortRangeMax *int64
+ PortRangeMin *int64
+ RemoteSecurityGroupId *string
+ ProtocolNumber *int64
+ ProtocolName *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a security group rule",
+ Long: "Creates a security group rule.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a security group rule for security group with ID "xxx" with direction "ingress"`,
+ `$ stackit security-group rule create --security-group-id xxx --direction ingress`,
+ ),
+ examples.NewExample(
+ `Create a security group rule for security group with ID "xxx" with direction "egress", protocol "icmp" and icmp parameters`,
+ `$ stackit security-group rule create --security-group-id xxx --direction egress --protocol-name icmp --icmp-parameter-code 0 --icmp-parameter-type 8`,
+ ),
+ examples.NewExample(
+ `Create a security group rule for security group with ID "xxx" with direction "ingress", protocol "tcp" and port range values`,
+ `$ stackit security-group rule create --security-group-id xxx --direction ingress --protocol-name tcp --port-range-max 24 --port-range-min 22`,
+ ),
+ examples.NewExample(
+ `Create a security group rule for security group with ID "xxx" with direction "ingress" and protocol number 1 `,
+ `$ stackit security-group rule create --security-group-id xxx --direction ingress --protocol-number 1`,
+ ),
+ ),
+ 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
+ }
+
+ 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 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create security group rule : %w", err)
+ }
+
+ return outputResult(params.Printer, model, projectLabel, securityGroupLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), securityGroupIdFlag, `The security group ID`)
+ cmd.Flags().String(directionFlag, "", `The direction of the traffic which the rule should match. The possible values are: "ingress", "egress"`)
+ cmd.Flags().String(descriptionFlag, "", `The rule description`)
+ cmd.Flags().String(etherTypeFlag, "", `The ethertype which the rule should match`)
+ cmd.Flags().Int64(icmpParameterCodeFlag, 0, `ICMP code. Can be set if the protocol is ICMP`)
+ cmd.Flags().Int64(icmpParameterTypeFlag, 0, `ICMP type. Can be set if the protocol is ICMP`)
+ cmd.Flags().String(ipRangeFlag, "", `The remote IP range which the rule should match`)
+ cmd.Flags().Int64(portRangeMaxFlag, 0, `The maximum port number. Should be greater or equal to the minimum. This should only be provided if the protocol is not ICMP`)
+ cmd.Flags().Int64(portRangeMinFlag, 0, `The minimum port number. Should be less or equal to the maximum. This should only be provided if the protocol is not ICMP`)
+ cmd.Flags().Var(flags.UUIDFlag(), remoteSecurityGroupIdFlag, `The remote security group which the rule should match`)
+ cmd.Flags().Int64(protocolNumberFlag, 0, `The protocol number which the rule should match. If a protocol is to be defined, either "protocol-name" or "protocol-number" must be provided`)
+ cmd.Flags().String(protocolNameFlag, "", `The protocol name which the rule should match. If a protocol is to be defined, either "protocol-name" or "protocol-number" must be provided`)
+
+ err := flags.MarkFlagsRequired(cmd, securityGroupIdFlag, directionFlag)
+ cmd.MarkFlagsMutuallyExclusive(protocolNumberFlag, protocolNameFlag)
+ 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,
+ SecurityGroupId: flags.FlagToStringValue(p, cmd, securityGroupIdFlag),
+ Direction: flags.FlagToStringPointer(p, cmd, directionFlag),
+ Description: flags.FlagToStringPointer(p, cmd, descriptionFlag),
+ EtherType: flags.FlagToStringPointer(p, cmd, etherTypeFlag),
+ IcmpParameterCode: flags.FlagToInt64Pointer(p, cmd, icmpParameterCodeFlag),
+ IcmpParameterType: flags.FlagToInt64Pointer(p, cmd, icmpParameterTypeFlag),
+ IpRange: flags.FlagToStringPointer(p, cmd, ipRangeFlag),
+ PortRangeMax: flags.FlagToInt64Pointer(p, cmd, portRangeMaxFlag),
+ PortRangeMin: flags.FlagToInt64Pointer(p, cmd, portRangeMinFlag),
+ RemoteSecurityGroupId: flags.FlagToStringPointer(p, cmd, remoteSecurityGroupIdFlag),
+ ProtocolNumber: flags.FlagToInt64Pointer(p, cmd, protocolNumberFlag),
+ ProtocolName: flags.FlagToStringPointer(p, cmd, protocolNameFlag),
+ }
+
+ 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.Region, model.SecurityGroupId)
+ icmpParameters := &iaas.ICMPParameters{}
+ portRange := &iaas.PortRange{}
+ protocol := &iaas.CreateProtocol{}
+
+ payload := iaas.CreateSecurityGroupRulePayload{
+ Direction: model.Direction,
+ Description: model.Description,
+ Ethertype: model.EtherType,
+ IpRange: model.IpRange,
+ RemoteSecurityGroupId: model.RemoteSecurityGroupId,
+ }
+
+ if model.IcmpParameterCode != nil || model.IcmpParameterType != nil {
+ icmpParameters.Code = model.IcmpParameterCode
+ icmpParameters.Type = model.IcmpParameterType
+
+ payload.IcmpParameters = icmpParameters
+ }
+
+ if model.PortRangeMax != nil || model.PortRangeMin != nil {
+ portRange.Max = model.PortRangeMax
+ portRange.Min = model.PortRangeMin
+
+ payload.PortRange = portRange
+ }
+
+ if model.ProtocolNumber != nil || model.ProtocolName != nil {
+ protocol.Int64 = model.ProtocolNumber
+ protocol.String = model.ProtocolName
+
+ payload.Protocol = protocol
+ }
+
+ if model.RemoteSecurityGroupId == nil {
+ payload.RemoteSecurityGroupId = nil
+ }
+
+ return req.CreateSecurityGroupRulePayload(payload)
+}
+
+func outputResult(p *print.Printer, model *inputModel, projectLabel, securityGroupName string, securityGroupRule *iaas.SecurityGroupRule) error {
+ if securityGroupRule == nil {
+ return fmt.Errorf("security group rule is empty")
+ }
+ 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
new file mode 100644
index 000000000..5099c7ca3
--- /dev/null
+++ b/internal/cmd/security-group/rule/create/create_test.go
@@ -0,0 +1,336 @@
+package create
+
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testSecurityGroupId = uuid.NewString()
+var testRemoteSecurityGroupId = uuid.NewString()
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ securityGroupIdFlag: testSecurityGroupId,
+ directionFlag: "ingress",
+ descriptionFlag: "example-description",
+ etherTypeFlag: "ether",
+ icmpParameterCodeFlag: "0",
+ icmpParameterTypeFlag: "8",
+ ipRangeFlag: "10.1.2.3",
+ portRangeMaxFlag: "24",
+ portRangeMinFlag: "22",
+ remoteSecurityGroupIdFlag: testRemoteSecurityGroupId,
+ protocolNumberFlag: "1",
+ protocolNameFlag: "icmp",
+ }
+ 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,
+ },
+ SecurityGroupId: testSecurityGroupId,
+ Direction: utils.Ptr("ingress"),
+ Description: utils.Ptr("example-description"),
+ EtherType: utils.Ptr("ether"),
+ IcmpParameterCode: utils.Ptr(int64(0)),
+ IcmpParameterType: utils.Ptr(int64(8)),
+ IpRange: utils.Ptr("10.1.2.3"),
+ PortRangeMax: utils.Ptr(int64(24)),
+ PortRangeMin: utils.Ptr(int64(22)),
+ RemoteSecurityGroupId: utils.Ptr(testRemoteSecurityGroupId),
+ ProtocolNumber: utils.Ptr(int64(1)),
+ ProtocolName: utils.Ptr("icmp"),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRuleRequest)) iaas.ApiCreateSecurityGroupRuleRequest {
+ request := testClient.CreateSecurityGroupRule(testCtx, testProjectId, testRegion, testSecurityGroupId)
+ request = request.CreateSecurityGroupRulePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateSecurityGroupRuleRequest)) iaas.ApiCreateSecurityGroupRuleRequest {
+ request := testClient.CreateSecurityGroupRule(testCtx, testProjectId, testRegion, testSecurityGroupId)
+ request = request.CreateSecurityGroupRulePayload(iaas.CreateSecurityGroupRulePayload{
+ Direction: utils.Ptr("ingress"),
+ })
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateSecurityGroupRulePayload)) iaas.CreateSecurityGroupRulePayload {
+ payload := iaas.CreateSecurityGroupRulePayload{
+ Direction: utils.Ptr("ingress"),
+ Description: utils.Ptr("example-description"),
+ Ethertype: utils.Ptr("ether"),
+ IcmpParameters: &iaas.ICMPParameters{
+ Code: utils.Ptr(int64(0)),
+ Type: utils.Ptr(int64(8)),
+ },
+ IpRange: utils.Ptr("10.1.2.3"),
+ PortRange: &iaas.PortRange{
+ Max: utils.Ptr(int64(24)),
+ Min: utils.Ptr(int64(22)),
+ },
+ Protocol: &iaas.CreateProtocol{
+ Int64: utils.Ptr(int64(1)),
+ String: utils.Ptr("icmp"),
+ },
+ RemoteSecurityGroupId: utils.Ptr(testRemoteSecurityGroupId),
+ }
+ 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(func(flagValues map[string]string) {
+ delete(flagValues, portRangeMaxFlag)
+ delete(flagValues, portRangeMinFlag)
+ delete(flagValues, protocolNumberFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.PortRangeMax = nil
+ model.PortRangeMin = nil
+ model.ProtocolNumber = nil
+ }),
+ },
+ {
+ description: "required only",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, descriptionFlag)
+ delete(flagValues, etherTypeFlag)
+ delete(flagValues, icmpParameterCodeFlag)
+ delete(flagValues, icmpParameterTypeFlag)
+ delete(flagValues, ipRangeFlag)
+ delete(flagValues, portRangeMaxFlag)
+ delete(flagValues, portRangeMinFlag)
+ delete(flagValues, remoteSecurityGroupIdFlag)
+ delete(flagValues, protocolNumberFlag)
+ delete(flagValues, protocolNameFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Description = nil
+ model.EtherType = nil
+ model.IcmpParameterCode = nil
+ model.IcmpParameterType = nil
+ model.IpRange = nil
+ model.PortRangeMax = nil
+ model.PortRangeMin = nil
+ model.RemoteSecurityGroupId = nil
+ model.ProtocolNumber = nil
+ model.ProtocolName = nil
+ }),
+ },
+ {
+ description: "direction missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, directionFlag)
+ delete(flagValues, protocolNumberFlag)
+ delete(flagValues, protocolNameFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "protocol is not icmp and port range values are provided",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[protocolNameFlag] = "not-icmp"
+ delete(flagValues, icmpParameterCodeFlag)
+ delete(flagValues, icmpParameterTypeFlag)
+ delete(flagValues, protocolNumberFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.IcmpParameterCode = nil
+ model.IcmpParameterType = nil
+ model.ProtocolName = utils.Ptr("not-icmp")
+ model.ProtocolNumber = nil
+ }),
+ },
+ {
+ 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,
+ },
+ }
+
+ 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 iaas.ApiCreateSecurityGroupRuleRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "only direction and security group id in payload",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Direction: utils.Ptr("ingress"),
+ SecurityGroupId: testSecurityGroupId,
+ },
+ expectedRequest: fixtureRequiredRequest(),
+ },
+ }
+ 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(iaas.NullableString{}),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ model *inputModel
+ projectLabel string
+ securityGroupName string
+ securityGroupRule *iaas.SecurityGroupRule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only security group rule",
+ args: args{
+ model: fixtureInputModel(),
+ securityGroupRule: &iaas.SecurityGroupRule{},
+ },
+ 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.securityGroupName, tt.args.securityGroupRule); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/security-group/rule/delete/delete.go b/internal/cmd/security-group/rule/delete/delete.go
new file mode 100644
index 000000000..388e3b0b7
--- /dev/null
+++ b/internal/cmd/security-group/rule/delete/delete.go
@@ -0,0 +1,122 @@
+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"
+ "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 (
+ securityGroupRuleIdArg = "SECURITY_GROUP_RULE_ID"
+
+ securityGroupIdFlag = "security-group-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ SecurityGroupRuleId string
+ SecurityGroupId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", securityGroupRuleIdArg),
+ Short: "Deletes a security group rule",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Deletes a security group rule.",
+ "If the security group rule is still in use, the deletion will fail",
+ ),
+ Args: args.SingleArg(securityGroupRuleIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete security group rule with ID "xxx" in security group with ID "yyy"`,
+ "$ stackit security-group rule delete xxx --security-group-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
+ }
+
+ 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
+ }
+
+ securityGroupRuleLabel, err := iaasUtils.GetSecurityGroupRuleName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupRuleId, model.SecurityGroupId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get security group rule name: %v", err)
+ securityGroupRuleLabel = model.SecurityGroupRuleId
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete security group rule: %w", err)
+ }
+
+ params.Printer.Info("Deleted security group rule %q from security group %q\n", securityGroupRuleLabel, securityGroupLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), securityGroupIdFlag, `The security group ID`)
+
+ err := flags.MarkFlagsRequired(cmd, securityGroupIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ securityGroupRuleId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ SecurityGroupRuleId: securityGroupRuleId,
+ 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.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
new file mode 100644
index 000000000..0d6a9b4cb
--- /dev/null
+++ b/internal/cmd/security-group/rule/delete/delete_test.go
@@ -0,0 +1,240 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testSecurityGroupId = uuid.NewString()
+var testSecurityGroupRuleId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testSecurityGroupRuleId,
+ }
+ 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,
+
+ securityGroupIdFlag: testSecurityGroupId,
+ }
+ 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,
+ },
+ SecurityGroupId: testSecurityGroupId,
+ SecurityGroupRuleId: testSecurityGroupRuleId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteSecurityGroupRuleRequest)) iaas.ApiDeleteSecurityGroupRuleRequest {
+ request := testClient.DeleteSecurityGroupRule(testCtx, testProjectId, testRegion, testSecurityGroupId, testSecurityGroupRuleId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ 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: "security group id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, securityGroupIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[securityGroupIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[securityGroupIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group rule id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "security group rule 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.ValidateArgs(tt.argValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiDeleteSecurityGroupRuleRequest
+ }{
+ {
+ 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/security-group/rule/describe/describe.go b/internal/cmd/security-group/rule/describe/describe.go
new file mode 100644
index 000000000..82486d989
--- /dev/null
+++ b/internal/cmd/security-group/rule/describe/describe.go
@@ -0,0 +1,164 @@
+package describe
+
+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/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"
+)
+
+const (
+ securityGroupRuleIdArg = "SECURITY_GROUP_RULE_ID"
+
+ securityGroupIdFlag = "security-group-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ SecurityGroupRuleId string
+ SecurityGroupId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", securityGroupRuleIdArg),
+ Short: "Shows details of a security group rule",
+ Long: "Shows details of a security group rule.",
+ Args: args.SingleArg(securityGroupRuleIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a security group rule with ID "xxx" in security group with ID "yyy"`,
+ "$ stackit security-group rule describe xxx --security-group-id yyy",
+ ),
+ examples.NewExample(
+ `Show details of a security group rule with ID "xxx" in security group with ID "yyy" in JSON format`,
+ "$ stackit security-group rule describe xxx --security-group-id yyy --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 security group rule: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), securityGroupIdFlag, `The security group ID`)
+
+ err := flags.MarkFlagsRequired(cmd, securityGroupIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ securityGroupRuleId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ SecurityGroupRuleId: securityGroupRuleId,
+ 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.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")
+ }
+ return p.OutputResult(outputFormat, securityGroupRule, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(securityGroupRule.Id))
+ table.AddSeparator()
+
+ if securityGroupRule.Protocol != nil {
+ if securityGroupRule.Protocol.Name != nil {
+ table.AddRow("PROTOCOL NAME", *securityGroupRule.Protocol.Name)
+ table.AddSeparator()
+ }
+
+ if securityGroupRule.Protocol.Number != nil {
+ table.AddRow("PROTOCOL NUMBER", *securityGroupRule.Protocol.Number)
+ table.AddSeparator()
+ }
+ }
+
+ table.AddRow("DIRECTION", utils.PtrString(securityGroupRule.Direction))
+ table.AddSeparator()
+
+ if securityGroupRule.PortRange != nil {
+ if securityGroupRule.PortRange.Min != nil {
+ table.AddRow("START PORT", *securityGroupRule.PortRange.Min)
+ table.AddSeparator()
+ }
+
+ if securityGroupRule.PortRange.Max != nil {
+ table.AddRow("END PORT", *securityGroupRule.PortRange.Max)
+ table.AddSeparator()
+ }
+ }
+
+ if securityGroupRule.Ethertype != nil {
+ table.AddRow("ETHER TYPE", *securityGroupRule.Ethertype)
+ table.AddSeparator()
+ }
+
+ if securityGroupRule.IpRange != nil {
+ table.AddRow("IP RANGE", *securityGroupRule.IpRange)
+ table.AddSeparator()
+ }
+
+ if securityGroupRule.RemoteSecurityGroupId != nil {
+ table.AddRow("REMOTE SECURITY GROUP", *securityGroupRule.RemoteSecurityGroupId)
+ table.AddSeparator()
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..f1af54485
--- /dev/null
+++ b/internal/cmd/security-group/rule/describe/describe_test.go
@@ -0,0 +1,239 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testSecurityGroupId = uuid.NewString()
+var testSecurityGroupRuleId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testSecurityGroupRuleId,
+ }
+ 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,
+
+ securityGroupIdFlag: testSecurityGroupId,
+ }
+ 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,
+ },
+ SecurityGroupId: testSecurityGroupId,
+ SecurityGroupRuleId: testSecurityGroupRuleId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetSecurityGroupRuleRequest)) iaas.ApiGetSecurityGroupRuleRequest {
+ request := testClient.GetSecurityGroupRule(testCtx, testProjectId, testRegion, testSecurityGroupId, testSecurityGroupRuleId)
+ 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: "security group id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, securityGroupIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[securityGroupIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[securityGroupIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "security group rule id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "security group rule 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 iaas.ApiGetSecurityGroupRuleRequest
+ }{
+ {
+ 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
+ securityGroupRule *iaas.SecurityGroupRule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "only security group rule as argument",
+ args: args{
+ securityGroupRule: &iaas.SecurityGroupRule{},
+ },
+ 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.securityGroupRule); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/security-group/rule/list/list.go b/internal/cmd/security-group/rule/list/list.go
new file mode 100644
index 000000000..1d39e5ed8
--- /dev/null
+++ b/internal/cmd/security-group/rule/list/list.go
@@ -0,0 +1,170 @@
+package list
+
+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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ limitFlag = "limit"
+
+ securityGroupIdFlag = "security-group-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+ SecurityGroupId string
+}
+
+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",
+ Long: "Lists all security group rules in a security group of a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all security group rules in security group with ID "xxx"`,
+ "$ stackit security-group rule list --security-group-id xxx",
+ ),
+ examples.NewExample(
+ `Lists all security group rules in security group with ID "xxx" in JSON format`,
+ "$ stackit security-group rule list --security-group-id xxx --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 security group rules in security group with ID "xxx"`,
+ "$ stackit security-group rule list --security-group-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 security group rules: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ 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
+ }
+
+ 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 rules found in security group %q for project %q\n", securityGroupLabel, projectLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, `Maximum number of entries to list`)
+ cmd.Flags().Var(flags.UUIDFlag(), securityGroupIdFlag, `The security group ID`)
+
+ err := flags.MarkFlagsRequired(cmd, 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, &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,
+ 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.Region, model.SecurityGroupId)
+}
+
+func outputResult(p *print.Printer, outputFormat string, securityGroupRules []iaas.SecurityGroupRule) error {
+ return p.OutputResult(outputFormat, securityGroupRules, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "ETHER TYPE", "DIRECTION", "PROTOCOL", "REMOTE SECURITY GROUP ID")
+
+ for _, securityGroupRule := range securityGroupRules {
+ etherType := utils.PtrStringDefault(securityGroupRule.Ethertype, "")
+
+ protocolName := ""
+ if securityGroupRule.Protocol != nil {
+ if securityGroupRule.Protocol.Name != nil {
+ protocolName = *securityGroupRule.Protocol.Name
+ }
+ }
+
+ table.AddRow(
+ utils.PtrString(securityGroupRule.Id),
+ etherType,
+ utils.PtrString(securityGroupRule.Direction),
+ protocolName,
+ utils.PtrString(securityGroupRule.RemoteSecurityGroupId),
+ )
+ table.AddSeparator()
+ }
+
+ 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
new file mode 100644
index 000000000..166b58e58
--- /dev/null
+++ b/internal/cmd/security-group/rule/list/list_test.go
@@ -0,0 +1,210 @@
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = 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,
+
+ limitFlag: "10",
+ 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,
+ },
+ Limit: utils.Ptr(int64(10)),
+ SecurityGroupId: testSecurityGroupId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListSecurityGroupRulesRequest)) iaas.ApiListSecurityGroupRulesRequest {
+ request := testClient.ListSecurityGroupRules(testCtx, testProjectId, testRegion, testSecurityGroupId)
+ 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 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: "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: "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.ApiListSecurityGroupRulesRequest
+ }{
+ {
+ 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
+ securityGroupRules []iaas.SecurityGroupRule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.securityGroupRules); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/security-group/rule/security_group_rule.go b/internal/cmd/security-group/rule/security_group_rule.go
new file mode 100644
index 000000000..fda58dd87
--- /dev/null
+++ b/internal/cmd/security-group/rule/security_group_rule.go
@@ -0,0 +1,32 @@
+package rule
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/rule/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/rule/delete"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "rule",
+ Short: "Provides functionality for security group rules",
+ Long: "Provides functionality for security group rules.",
+ 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/security-group/security_group.go b/internal/cmd/security-group/security_group.go
new file mode 100644
index 000000000..ef613d054
--- /dev/null
+++ b/internal/cmd/security-group/security_group.go
@@ -0,0 +1,39 @@
+package security_group
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/security-group/list"
+ "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/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: "Manage security groups",
+ Long: "Manage the lifecycle of security groups and rules.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+func addSubcommands(cmd *cobra.Command, params *types.CmdParams) {
+ cmd.AddCommand(
+ 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
new file mode 100644
index 000000000..6f612f744
--- /dev/null
+++ b/internal/cmd/security-group/update/update.go
@@ -0,0 +1,134 @@
+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/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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Labels *map[string]string
+ Description *string
+ Name *string
+ SecurityGroupId string
+}
+
+const groupNameArg = "GROUP_ID"
+
+const (
+ nameArg = "name"
+ descriptionArg = "description"
+ labelsArg = "labels"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", groupNameArg),
+ Short: "Updates a security group",
+ Long: "Updates a named security group",
+ Args: args.SingleArg(groupNameArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(`Update the name of group "xxx"`, `$ stackit security-group update xxx --name my-new-name`),
+ examples.NewExample(`Update the labels of group "xxx"`, `$ stackit security-group update xxx --labels label1=value1,label2=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
+ }
+
+ groupLabel, err := iaasUtils.GetSecurityGroupName(ctx, apiClient, model.ProjectId, model.Region, model.SecurityGroupId)
+ if err != nil {
+ params.Printer.Warn("cannot retrieve groupname: %v", err)
+ groupLabel = model.SecurityGroupId
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update security group: %w", err)
+ }
+ params.Printer.Info("Updated security group \"%v\" for %q\n", utils.PtrString(resp.Name), projectLabel)
+
+ return nil
+ },
+ }
+
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(nameArg, "", "The name of the security group.")
+ cmd.Flags().String(descriptionArg, "", "An optional description of the security group.")
+ cmd.Flags().StringToString(labelsArg, nil, "Labels are key-value string pairs which can be attached to a network-interface. E.g. '--labels key1=value1,key2=value2,...'")
+}
+
+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,
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelsArg),
+ Description: flags.FlagToStringPointer(p, cmd, descriptionArg),
+ Name: flags.FlagToStringPointer(p, cmd, nameArg),
+ SecurityGroupId: cliArgs[0],
+ }
+
+ if model.Labels == nil && model.Description == nil && model.Name == nil {
+ return nil, fmt.Errorf("no flags have been passed")
+ }
+
+ 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.Region, model.SecurityGroupId)
+ payload := iaas.NewUpdateSecurityGroupPayload()
+ payload.Description = model.Description
+ payload.Labels = utils.ConvertStringMapToInterfaceMap(model.Labels)
+ payload.Name = model.Name
+ request = request.UpdateSecurityGroupPayload(*payload)
+
+ return request
+}
diff --git a/internal/cmd/security-group/update/update_test.go b/internal/cmd/security-group/update/update_test.go
new file mode 100644
index 000000000..115cdb32c
--- /dev/null
+++ b/internal/cmd/security-group/update/update_test.go
@@ -0,0 +1,302 @@
+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/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var (
+ testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+ testClient = &iaas.APIClient{}
+ testProjectId = uuid.NewString()
+ testGroupId = []string{uuid.NewString()}
+ testName = "new-security-group"
+ testDescription = "a test description"
+ testLabels = map[string]string{
+ "fooKey": "fooValue",
+ "barKey": "barValue",
+ "bazKey": "bazValue",
+ }
+)
+
+func toStringAnyMapPtr(m map[string]string) map[string]any {
+ if m == nil {
+ return nil
+ }
+ result := map[string]any{}
+ for k, v := range m {
+ result[k] = v
+ }
+ return result
+}
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ descriptionArg: testDescription,
+ labelsArg: "fooKey=fooValue,barKey=barValue,bazKey=bazValue",
+ nameArg: testName,
+ }
+ 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,
+ },
+ Labels: &testLabels,
+ Description: &testDescription,
+ Name: &testName,
+ SecurityGroupId: testGroupId[0],
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdateSecurityGroupRequest)) iaas.ApiUpdateSecurityGroupRequest {
+ request := testClient.UpdateSecurityGroup(testCtx, testProjectId, testRegion, testGroupId[0])
+ request = request.UpdateSecurityGroupPayload(iaas.UpdateSecurityGroupPayload{
+ Description: &testDescription,
+ Labels: utils.Ptr(toStringAnyMapPtr(testLabels)),
+ Name: &testName,
+ })
+ 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(),
+ isValid: true,
+ args: testGroupId,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values but valid group id",
+ flagValues: map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ },
+ args: testGroupId,
+ isValid: false,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ model.Name = nil
+ model.Description = nil
+ }),
+ },
+ {
+ description: "project id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, globalflags.ProjectIdFlag)
+ }),
+ args: testGroupId,
+ isValid: false,
+ },
+ {
+ description: "project id invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[globalflags.ProjectIdFlag] = ""
+ }),
+ args: testGroupId,
+ isValid: false,
+ },
+ {
+ description: "project id invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[globalflags.ProjectIdFlag] = "invalid-uuid"
+ }),
+ args: testGroupId,
+ isValid: false,
+ },
+ {
+ description: "no name passed",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, nameArg)
+ }),
+ args: testGroupId,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Name = nil
+ }),
+ isValid: true,
+ },
+ {
+ description: "no description passed",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, descriptionArg)
+ }),
+ args: testGroupId,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Description = nil
+ }),
+ isValid: true,
+ },
+ {
+ description: "no labels",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, labelsArg)
+ }),
+ args: testGroupId,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ isValid: true,
+ },
+ {
+ description: "single label",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelsArg] = "foo=bar"
+ }),
+ args: testGroupId,
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = &map[string]string{
+ "foo": "bar",
+ }
+ }),
+ },
+ {
+ description: "no group id passed",
+ flagValues: fixtureFlagValues(),
+ args: nil,
+ isValid: false,
+ },
+ {
+ description: "invalid group id passed",
+ flagValues: fixtureFlagValues(),
+ args: []string{"foobar"},
+ isValid: false,
+ },
+ {
+ description: "multiple group ids passed",
+ flagValues: fixtureFlagValues(),
+ args: []string{uuid.NewString(), uuid.NewString()},
+ isValid: false,
+ },
+ }
+
+ 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 {
+ 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)
+ }
+
+ 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 iaas.ApiUpdateSecurityGroupRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "no labels",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.Labels = nil
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiUpdateSecurityGroupRequest) {
+ *request = (*request).UpdateSecurityGroupPayload(iaas.UpdateSecurityGroupPayload{
+ Description: &testDescription,
+ Labels: nil,
+ Name: &testName,
+ })
+ }),
+ },
+ }
+
+ 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/backup/backup.go b/internal/cmd/server/backup/backup.go
new file mode 100644
index 000000000..77d290a42
--- /dev/null
+++ b/internal/cmd/server/backup/backup.go
@@ -0,0 +1,42 @@
+package backup
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/create"
+ del "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/disable"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/enable"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/list"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/restore"
+ "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/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 server backups",
+ Long: "Provides functionality for server backups.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+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
new file mode 100644
index 000000000..50b36dce4
--- /dev/null
+++ b/internal/cmd/server/backup/create/create.go
@@ -0,0 +1,152 @@
+package create
+
+import (
+ "context"
+ "fmt"
+
+ "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"
+ 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"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ backupNameFlag = "name"
+ backupRetentionPeriodFlag = "retention-period"
+ backupVolumeIdsFlag = "volume-ids"
+ serverIdFlag = "server-id"
+
+ defaultRetentionPeriod = 14
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ ServerId string
+ BackupName string
+ BackupRetentionPeriod int64
+ BackupVolumeIds []string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a Server Backup.",
+ Long: "Creates a Server Backup. Operation always is async.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a Server Backup with name "mybackup"`,
+ `$ stackit server backup create --server-id xxx --name=mybackup`),
+ examples.NewExample(
+ `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, 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 := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create Server Backup: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().StringP(backupNameFlag, "b", "", "Backup name")
+ cmd.Flags().Int64P(backupRetentionPeriodFlag, "d", defaultRetentionPeriod, "Backup retention period (in days)")
+ cmd.Flags().VarP(flags.UUIDSliceFlag(), backupVolumeIdsFlag, "i", "Backup volume IDs, as comma separated UUID values.")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag, backupNameFlag)
+ 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),
+ BackupRetentionPeriod: flags.FlagWithDefaultToInt64Value(p, cmd, backupRetentionPeriodFlag),
+ BackupName: flags.FlagToStringValue(p, cmd, backupNameFlag),
+ BackupVolumeIds: flags.FlagToStringSliceValue(p, cmd, backupVolumeIdsFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) (serverbackup.ApiCreateBackupRequest, error) {
+ req := apiClient.CreateBackup(ctx, model.ProjectId, model.ServerId, model.Region)
+ payload := serverbackup.CreateBackupPayload{
+ Name: &model.BackupName,
+ RetentionPeriod: &model.BackupRetentionPeriod,
+ VolumeIds: &model.BackupVolumeIds,
+ }
+ if model.BackupVolumeIds == nil {
+ payload.VolumeIds = nil
+ }
+ req = req.CreateBackupPayload(payload)
+ return req, nil
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel string, resp serverbackup.BackupJob) error {
+ 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
new file mode 100644
index 000000000..07d305320
--- /dev/null
+++ b/internal/cmd/server/backup/create/create_test.go
@@ -0,0 +1,204 @@
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testBackupVolumeId = uuid.NewString()
+var testRegion = "eu01"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ serverIdFlag: testServerId,
+ backupNameFlag: "example-backup-name",
+ backupRetentionPeriodFlag: "14",
+ backupVolumeIdsFlag: testBackupVolumeId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ BackupName: "example-backup-name",
+ BackupRetentionPeriod: int64(14),
+ BackupVolumeIds: []string{testBackupVolumeId},
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiCreateBackupRequest)) serverbackup.ApiCreateBackupRequest {
+ request := testClient.CreateBackup(testCtx, testProjectId, testServerId, testRegion)
+ request = request.CreateBackupPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *serverbackup.CreateBackupPayload)) serverbackup.CreateBackupPayload {
+ payload := serverbackup.CreateBackupPayload{
+ Name: utils.Ptr("example-backup-name"),
+ RetentionPeriod: utils.Ptr(int64(14)),
+ VolumeIds: utils.Ptr([]string{testBackupVolumeId}),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "with defaults",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, backupRetentionPeriodFlag)
+ }),
+ 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,
+ },
+ }
+
+ 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 serverbackup.ApiCreateBackupRequest
+ isValid bool
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error building request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ serverLabel string
+ resp serverbackup.BackupJob
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serverLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/backup/delete/delete.go b/internal/cmd/server/backup/delete/delete.go
new file mode 100644
index 000000000..5eedcde2d
--- /dev/null
+++ b/internal/cmd/server/backup/delete/delete.go
@@ -0,0 +1,106 @@
+package delete
+
+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/services/serverbackup/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ backupIdArg = "BACKUP_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ BackupId string
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", backupIdArg),
+ Short: "Deletes a Server Backup.",
+ Long: "Deletes a Server Backup. Operation always is async.",
+ Args: args.SingleArg(backupIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete a Server Backup with ID "xxx" for server "zzz"`,
+ "$ stackit server backup delete xxx --server-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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete Server Backup: %w", err)
+ }
+
+ params.Printer.Info("Triggered deletion of server backup %q\n", model.BackupId)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiDeleteBackupRequest {
+ req := apiClient.DeleteBackup(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupId)
+ return req
+}
diff --git a/internal/cmd/server/backup/delete/delete_test.go b/internal/cmd/server/backup/delete/delete_test.go
new file mode 100644
index 000000000..1b90f5c00
--- /dev/null
+++ b/internal/cmd/server/backup/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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testBackupId = uuid.NewString()
+var testRegion = "eu01"
+
+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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ BackupId: testBackupId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiDeleteBackupRequest)) serverbackup.ApiDeleteBackupRequest {
+ request := testClient.DeleteBackup(testCtx, testProjectId, testServerId, 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,
+ },
+ {
+ 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) {
+ 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 serverbackup.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/server/backup/describe/describe.go b/internal/cmd/server/backup/describe/describe.go
new file mode 100644
index 000000000..b303028c3
--- /dev/null
+++ b/internal/cmd/server/backup/describe/describe.go
@@ -0,0 +1,138 @@
+package describe
+
+import (
+ "context"
+ "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"
+ "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/serverbackup/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/serverbackup"
+)
+
+const (
+ backupIdArg = "BACKUP_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ BackupId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", backupIdArg),
+ Short: "Shows details of a Server Backup",
+ Long: "Shows details of a Server Backup.",
+ Args: args.SingleArg(backupIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of a Server Backup with id "my-backup-id"`,
+ "$ stackit server backup describe my-backup-id"),
+ examples.NewExample(
+ `Get details of a Server Backup with id "my-backup-id" in JSON format`,
+ "$ stackit server backup describe my-backup-id --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 server backup: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ BackupId: backupId,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiGetBackupRequest {
+ req := apiClient.GetBackup(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, backup serverbackup.Backup) error {
+ 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 (GB)", utils.PtrString(backup.Size))
+ table.AddSeparator()
+ table.AddRow("STATUS", utils.PtrString(backup.Status))
+ table.AddSeparator()
+ table.AddRow("CREATED AT", utils.PtrString(backup.CreatedAt))
+ table.AddSeparator()
+ table.AddRow("EXPIRES AT", utils.PtrString(backup.ExpireAt))
+ table.AddSeparator()
+
+ lastRestored := utils.PtrStringDefault(backup.LastRestoredAt, "")
+ table.AddRow("LAST RESTORED AT", lastRestored)
+ table.AddSeparator()
+ volBackups := ""
+ if backups := backup.VolumeBackups; backups != nil {
+ volBackups = strconv.Itoa(len(*backups))
+ }
+ table.AddRow("VOLUME BACKUPS", volBackups)
+ table.AddSeparator()
+
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/server/backup/describe/describe_test.go b/internal/cmd/server/backup/describe/describe_test.go
new file mode 100644
index 000000000..1550a9c4e
--- /dev/null
+++ b/internal/cmd/server/backup/describe/describe_test.go
@@ -0,0 +1,203 @@
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testBackupId = uuid.NewString()
+var testRegion = "eu01"
+
+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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ BackupId: testBackupId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiGetBackupRequest)) serverbackup.ApiGetBackupRequest {
+ request := testClient.GetBackup(testCtx, testProjectId, testServerId, 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,
+ },
+ {
+ 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) {
+ testutils.TestParseInput(t, NewCmd, parseInput, tt.expectedModel, tt.argValues, tt.flagValues, tt.isValid)
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ isValid bool
+ expectedRequest serverbackup.ApiGetBackupRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ backup serverbackup.Backup
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "output format json",
+ args: args{
+ outputFormat: print.JSONOutputFormat,
+ backup: serverbackup.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/server/backup/disable/disable.go b/internal/cmd/server/backup/disable/disable.go
new file mode 100644
index 000000000..4aecf6553
--- /dev/null
+++ b/internal/cmd/server/backup/disable/disable.go
@@ -0,0 +1,123 @@
+package disable
+
+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"
+ 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"
+ serverbackupUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/serverbackup/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "disable",
+ Short: "Disables Server Backup service",
+ Long: "Disables Server Backup service.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Disable Server Backup functionality for your server.`,
+ "$ stackit server backup disable --server-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
+ }
+
+ serverLabel := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ canDisable, err := serverbackupUtils.CanDisableBackupService(ctx, apiClient, model.ProjectId, model.ServerId, model.Region)
+ if err != nil {
+ return err
+ }
+ if !canDisable {
+ params.Printer.Info("Cannot disable backup service for server %s - existing backups or existing backup schedules found\n", serverLabel)
+ return nil
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("disable server backup service: %w", err)
+ }
+
+ params.Printer.Info("Disabled Server Backup service for server %s\n", serverLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiDisableServiceResourceRequest {
+ req := apiClient.DisableServiceResource(ctx, model.ProjectId, model.ServerId, model.Region)
+ return req
+}
diff --git a/internal/cmd/server/backup/disable/disable_test.go b/internal/cmd/server/backup/disable/disable_test.go
new file mode 100644
index 000000000..9e74ae6ee
--- /dev/null
+++ b/internal/cmd/server/backup/disable/disable_test.go
@@ -0,0 +1,141 @@
+package disable
+
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testRegion = "eu01"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiDisableServiceResourceRequest)) serverbackup.ApiDisableServiceResourceRequest {
+ request := testClient.DisableServiceResource(testCtx, testProjectId, testServerId, 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: "server id missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ 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,
+ },
+ }
+
+ 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 serverbackup.ApiDisableServiceResourceRequest
+ }{
+ {
+ 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/backup/enable/enable.go b/internal/cmd/server/backup/enable/enable.go
new file mode 100644
index 000000000..7f6854be3
--- /dev/null
+++ b/internal/cmd/server/backup/enable/enable.go
@@ -0,0 +1,117 @@
+package enable
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "enable",
+ Short: "Enables Server Backup service",
+ Long: "Enables Server Backup service.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Enable Server Backup functionality for your server`,
+ "$ stackit server backup enable --server-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
+ }
+
+ serverLabel := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ if !strings.Contains(err.Error(), "Tried to activate already active service") {
+ return fmt.Errorf("enable Server Backup: %w", err)
+ }
+ }
+
+ params.Printer.Info("Enabled backup service for server %s\n", serverLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiEnableServiceResourceRequest {
+ payload := serverbackup.EnableServiceResourcePayload{}
+ req := apiClient.EnableServiceResource(ctx, model.ProjectId, model.ServerId, model.Region).EnableServiceResourcePayload(payload)
+ return req
+}
diff --git a/internal/cmd/server/backup/enable/enable_test.go b/internal/cmd/server/backup/enable/enable_test.go
new file mode 100644
index 000000000..b7035a762
--- /dev/null
+++ b/internal/cmd/server/backup/enable/enable_test.go
@@ -0,0 +1,141 @@
+package enable
+
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testRegion = "eu01"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiEnableServiceResourceRequest)) serverbackup.ApiEnableServiceResourceRequest {
+ request := testClient.EnableServiceResource(testCtx, testProjectId, testServerId, testRegion).EnableServiceResourcePayload(serverbackup.EnableServiceResourcePayload{})
+ 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: "server id is missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ 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,
+ },
+ }
+
+ 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 serverbackup.ApiEnableServiceResourceRequest
+ }{
+ {
+ 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/backup/list/list.go b/internal/cmd/server/backup/list/list.go
new file mode 100644
index 000000000..7f60f6a70
--- /dev/null
+++ b/internal/cmd/server/backup/list/list.go
@@ -0,0 +1,162 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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-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"
+ 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 (
+ limitFlag = "limit"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ Limit *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all server backups",
+ Long: "Lists all server backups.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all backups for a server with ID "xxx"`,
+ "$ stackit server backup list --server-id xxx"),
+ examples.NewExample(
+ `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, 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 server backups: %w", err)
+ }
+ backups := *resp.Items
+ if len(backups) == 0 {
+ serverLabel := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+ params.Printer.Info("No backups found for server %s\n", serverLabel)
+ return nil
+ }
+
+ // 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().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ Limit: limit,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiListBackupsRequest {
+ req := apiClient.ListBackups(ctx, model.ProjectId, model.ServerId, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, backups []serverbackup.Backup) error {
+ 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 {
+ s := backups[i]
+
+ lastRestored := utils.PtrStringDefault(s.LastRestoredAt, "")
+ var volBackups int
+ if s.VolumeBackups != nil {
+ volBackups = len(*s.VolumeBackups)
+ }
+ table.AddRow(
+ utils.PtrString(s.Id),
+ utils.PtrString(s.Name),
+ utils.PtrString(s.Size),
+ utils.PtrString(s.Status),
+ utils.PtrString(s.CreatedAt),
+ utils.PtrString(s.ExpireAt),
+ lastRestored,
+ volBackups,
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..a316ee0c4
--- /dev/null
+++ b/internal/cmd/server/backup/list/list_test.go
@@ -0,0 +1,190 @@
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testRegion = "eu01"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ limitFlag: "10",
+ serverIdFlag: testServerId,
+ }
+ 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)),
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiListBackupsRequest)) serverbackup.ApiListBackupsRequest {
+ request := testClient.ListBackups(testCtx, testProjectId, testServerId, 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 serverbackup.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 []serverbackup.Backup
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty backup",
+ args: args{
+ backups: []serverbackup.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/server/backup/restore/restore.go b/internal/cmd/server/backup/restore/restore.go
new file mode 100644
index 000000000..b55b2b465
--- /dev/null
+++ b/internal/cmd/server/backup/restore/restore.go
@@ -0,0 +1,127 @@
+package restore
+
+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/services/serverbackup/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ backupIdArg = "BACKUP_ID"
+ serverIdFlag = "server-id"
+ startServerAfterRestoreFlag = "start-server-after-restore"
+ backupVolumeIdsFlag = "volume-ids"
+
+ defaultStartServerAfterRestore = false
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ BackupId string
+ ServerId string
+ StartServerAfterRestore bool
+ BackupVolumeIds []string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("restore %s", backupIdArg),
+ Short: "Restores a Server Backup.",
+ Long: "Restores a Server Backup. Operation always is async.",
+ Args: args.SingleArg(backupIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Restore a Server Backup with ID "xxx" for server "zzz"`,
+ "$ stackit server backup restore xxx --server-id=zzz"),
+ examples.NewExample(
+ `Restore a Server Backup with ID "xxx" for server "zzz" and start the server afterwards`,
+ "$ stackit server backup restore xxx --server-id=zzz --start-server-after-restore"),
+ ),
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("restore Server Backup: %w", err)
+ }
+
+ params.Printer.Info("Triggered restoring of server backup %q\n", model.BackupId)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().VarP(flags.UUIDSliceFlag(), backupVolumeIdsFlag, "i", "Backup volume IDs, as comma separated UUID values.")
+ cmd.Flags().BoolP(startServerAfterRestoreFlag, "u", defaultStartServerAfterRestore, "Should the server start after the backup restoring.")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ BackupVolumeIds: flags.FlagToStringSliceValue(p, cmd, backupVolumeIdsFlag),
+ StartServerAfterRestore: flags.FlagToBoolValue(p, cmd, startServerAfterRestoreFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiRestoreBackupRequest {
+ req := apiClient.RestoreBackup(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupId)
+ payload := serverbackup.RestoreBackupPayload{
+ StartServerAfterRestore: &model.StartServerAfterRestore,
+ VolumeIds: &model.BackupVolumeIds,
+ }
+ if model.BackupVolumeIds == nil {
+ payload.VolumeIds = nil
+ }
+ req = req.RestoreBackupPayload(payload)
+ return req
+}
diff --git a/internal/cmd/server/backup/restore/restore_test.go b/internal/cmd/server/backup/restore/restore_test.go
new file mode 100644
index 000000000..9f890f3c2
--- /dev/null
+++ b/internal/cmd/server/backup/restore/restore_test.go
@@ -0,0 +1,165 @@
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testBackupId = uuid.NewString()
+var testRegion = "eu01"
+
+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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ BackupId: testBackupId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiRestoreBackupRequest)) serverbackup.ApiRestoreBackupRequest {
+ request := testClient.RestoreBackup(testCtx, testProjectId, testServerId, testRegion, testBackupId)
+ startServerAfterRestore := false
+ request = request.RestoreBackupPayload(serverbackup.RestoreBackupPayload{StartServerAfterRestore: &startServerAfterRestore})
+ 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,
+ },
+ }
+
+ 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 serverbackup.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/server/backup/schedule/create/create.go b/internal/cmd/server/backup/schedule/create/create.go
new file mode 100644
index 000000000..6b722c0b0
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/create/create.go
@@ -0,0 +1,171 @@
+package create
+
+import (
+ "context"
+ "fmt"
+
+ "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"
+ 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"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ backupScheduleNameFlag = "backup-schedule-name"
+ enabledFlag = "enabled"
+ rruleFlag = "rrule"
+ backupNameFlag = "backup-name"
+ backupVolumeIdsFlag = "backup-volume-ids"
+ backupRetentionPeriodFlag = "backup-retention-period"
+ serverIdFlag = "server-id"
+
+ defaultRrule = "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"
+ defaultRetentionPeriod = 14
+ defaultEnabled = true
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ ServerId string
+ BackupScheduleName string
+ Enabled bool
+ Rrule string
+ BackupName string
+ BackupRetentionPeriod int64
+ BackupVolumeIds []string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a Server Backup Schedule",
+ Long: "Creates a Server Backup Schedule.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a Server Backup Schedule with name "myschedule" and backup name "mybackup"`,
+ `$ stackit server backup schedule create --server-id xxx --backup-name=mybackup --backup-schedule-name=myschedule`),
+ examples.NewExample(
+ `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, 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 := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create Server Backup Schedule: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().StringP(backupScheduleNameFlag, "n", "", "Backup schedule name")
+ cmd.Flags().StringP(backupNameFlag, "b", "", "Backup name")
+ cmd.Flags().Int64P(backupRetentionPeriodFlag, "d", defaultRetentionPeriod, "Backup retention period (in days)")
+ cmd.Flags().BoolP(enabledFlag, "e", defaultEnabled, "Is the server backup schedule enabled")
+ cmd.Flags().StringP(rruleFlag, "r", defaultRrule, "Backup RRULE (recurrence rule)")
+ cmd.Flags().VarP(flags.UUIDSliceFlag(), backupVolumeIdsFlag, "i", "Backup volume IDs, as comma separated UUID values.")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag, backupScheduleNameFlag, backupNameFlag)
+ 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),
+ BackupRetentionPeriod: flags.FlagWithDefaultToInt64Value(p, cmd, backupRetentionPeriodFlag),
+ BackupScheduleName: flags.FlagToStringValue(p, cmd, backupScheduleNameFlag),
+ BackupName: flags.FlagToStringValue(p, cmd, backupNameFlag),
+ Rrule: flags.FlagWithDefaultToStringValue(p, cmd, rruleFlag),
+ Enabled: flags.FlagToBoolValue(p, cmd, enabledFlag),
+ BackupVolumeIds: flags.FlagToStringSliceValue(p, cmd, backupVolumeIdsFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) (serverbackup.ApiCreateBackupScheduleRequest, error) {
+ req := apiClient.CreateBackupSchedule(ctx, model.ProjectId, model.ServerId, model.Region)
+ backupProperties := serverbackup.BackupProperties{
+ Name: &model.BackupName,
+ RetentionPeriod: &model.BackupRetentionPeriod,
+ VolumeIds: &model.BackupVolumeIds,
+ }
+ if model.BackupVolumeIds == nil {
+ backupProperties.VolumeIds = nil
+ }
+ req = req.CreateBackupSchedulePayload(serverbackup.CreateBackupSchedulePayload{
+ Enabled: &model.Enabled,
+ Name: &model.BackupScheduleName,
+ Rrule: &model.Rrule,
+ BackupProperties: &backupProperties,
+ })
+ return req, nil
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel string, resp serverbackup.BackupSchedule) error {
+ 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
new file mode 100644
index 000000000..548a93bd4
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/create/create_test.go
@@ -0,0 +1,214 @@
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+var testRegion = "eu01"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ serverIdFlag: testServerId,
+ backupScheduleNameFlag: "example-backup-schedule-name",
+ enabledFlag: "true",
+ rruleFlag: defaultRrule,
+ backupNameFlag: "example-backup-name",
+ backupRetentionPeriodFlag: "14",
+ backupVolumeIdsFlag: testVolumeId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ BackupScheduleName: "example-backup-schedule-name",
+ Enabled: defaultEnabled,
+ Rrule: defaultRrule,
+ BackupName: "example-backup-name",
+ BackupRetentionPeriod: int64(14),
+ BackupVolumeIds: []string{testVolumeId},
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiCreateBackupScheduleRequest)) serverbackup.ApiCreateBackupScheduleRequest {
+ request := testClient.CreateBackupSchedule(testCtx, testProjectId, testServerId, testRegion)
+ request = request.CreateBackupSchedulePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *serverbackup.CreateBackupSchedulePayload)) serverbackup.CreateBackupSchedulePayload {
+ payload := serverbackup.CreateBackupSchedulePayload{
+ Name: utils.Ptr("example-backup-schedule-name"),
+ Enabled: utils.Ptr(defaultEnabled),
+ Rrule: utils.Ptr("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"),
+ BackupProperties: &serverbackup.BackupProperties{
+ Name: utils.Ptr("example-backup-name"),
+ RetentionPeriod: utils.Ptr(int64(14)),
+ VolumeIds: utils.Ptr([]string{testVolumeId}),
+ },
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "with defaults",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, backupRetentionPeriodFlag)
+ }),
+ 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,
+ },
+ }
+
+ 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 serverbackup.ApiCreateBackupScheduleRequest
+ isValid bool
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error building request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat, serverLabel string
+ resp serverbackup.BackupSchedule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serverLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/backup/schedule/delete/delete.go b/internal/cmd/server/backup/schedule/delete/delete.go
new file mode 100644
index 000000000..42e4c07ef
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/delete/delete.go
@@ -0,0 +1,118 @@
+package delete
+
+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"
+ 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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ scheduleIdArg = "SCHEDULE_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ScheduleId string
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", scheduleIdArg),
+ Short: "Deletes a Server Backup Schedule",
+ Long: "Deletes a Server Backup Schedule.",
+ Args: args.SingleArg(scheduleIdArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete a Server Backup Schedule with ID "xxx" for server "zzz"`,
+ "$ stackit server backup schedule delete xxx --server-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
+ }
+
+ serverLabel := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete Server Backup Schedule: %w", err)
+ }
+
+ params.Printer.Info("Deleted server backup schedule %q\n", model.ScheduleId)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ scheduleId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ScheduleId: scheduleId,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiDeleteBackupScheduleRequest {
+ req := apiClient.DeleteBackupSchedule(ctx, model.ProjectId, model.ServerId, model.Region, model.ScheduleId)
+ return req
+}
diff --git a/internal/cmd/server/backup/schedule/delete/delete_test.go b/internal/cmd/server/backup/schedule/delete/delete_test.go
new file mode 100644
index 000000000..5e0f108ca
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testBackupScheduleId = "5"
+var testRegion = "eu01"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testBackupScheduleId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ ScheduleId: testBackupScheduleId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiDeleteBackupScheduleRequest)) serverbackup.ApiDeleteBackupScheduleRequest {
+ request := testClient.DeleteBackupSchedule(testCtx, testProjectId, testServerId, testRegion, testBackupScheduleId)
+ 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,
+ },
+ }
+
+ 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 serverbackup.ApiDeleteBackupScheduleRequest
+ }{
+ {
+ 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/backup/schedule/describe/describe.go b/internal/cmd/server/backup/schedule/describe/describe.go
new file mode 100644
index 000000000..a4e3b1f21
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/describe/describe.go
@@ -0,0 +1,130 @@
+package describe
+
+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/services/serverbackup/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/serverbackup"
+)
+
+const (
+ backupScheduleIdArg = "BACKUP_SCHEDULE_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ BackupScheduleId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", backupScheduleIdArg),
+ Short: "Shows details of a Server Backup Schedule",
+ Long: "Shows details of a Server Backup Schedule.",
+ Args: args.SingleArg(backupScheduleIdArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of a Server Backup Schedule with id "my-schedule-id"`,
+ "$ stackit server backup schedule describe my-schedule-id"),
+ examples.NewExample(
+ `Get details of a Server Backup Schedule with id "my-schedule-id" in JSON format`,
+ "$ stackit server backup schedule describe my-schedule-id --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 server backup schedule: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ backupScheduleId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ BackupScheduleId: backupScheduleId,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiGetBackupScheduleRequest {
+ req := apiClient.GetBackupSchedule(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupScheduleId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, schedule serverbackup.BackupSchedule) error {
+ return p.OutputResult(outputFormat, schedule, func() error {
+ table := tables.NewTable()
+ table.AddRow("SCHEDULE ID", utils.PtrString(schedule.Id))
+ table.AddSeparator()
+ table.AddRow("SCHEDULE NAME", utils.PtrString(schedule.Name))
+ table.AddSeparator()
+ table.AddRow("ENABLED", utils.PtrString(schedule.Enabled))
+ table.AddSeparator()
+ table.AddRow("RRULE", utils.PtrString(schedule.Rrule))
+ table.AddSeparator()
+ if schedule.BackupProperties != nil {
+ table.AddRow("BACKUP NAME", utils.PtrString(schedule.BackupProperties.Name))
+ table.AddSeparator()
+ table.AddRow("BACKUP RETENTION DAYS", utils.PtrString(schedule.BackupProperties.RetentionPeriod))
+ table.AddSeparator()
+ ids := schedule.BackupProperties.VolumeIds
+ table.AddRow("BACKUP VOLUME IDS", utils.JoinStringPtr(ids, "\n"))
+ }
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/server/backup/schedule/describe/describe_test.go b/internal/cmd/server/backup/schedule/describe/describe_test.go
new file mode 100644
index 000000000..e5808e545
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/describe/describe_test.go
@@ -0,0 +1,230 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testBackupScheduleId = "5"
+var testRegion = "eu01"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testBackupScheduleId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ BackupScheduleId: testBackupScheduleId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiGetBackupScheduleRequest)) serverbackup.ApiGetBackupScheduleRequest {
+ request := testClient.GetBackupSchedule(testCtx, testProjectId, testServerId, testRegion, testBackupScheduleId)
+ 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,
+ },
+ }
+
+ 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
+ isValid bool
+ expectedRequest serverbackup.ApiGetBackupScheduleRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ schedule serverbackup.BackupSchedule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "nil backup properties",
+ args: args{
+ schedule: serverbackup.BackupSchedule{
+ BackupProperties: nil,
+ },
+ },
+ },
+ {
+ name: "empty backup properties",
+ args: args{
+ schedule: serverbackup.BackupSchedule{
+ BackupProperties: &serverbackup.BackupProperties{},
+ },
+ },
+ },
+ {
+ name: "nil volume ids",
+ args: args{
+ schedule: serverbackup.BackupSchedule{
+ BackupProperties: &serverbackup.BackupProperties{
+ VolumeIds: nil,
+ },
+ },
+ },
+ },
+ {
+ name: "empty volume ids",
+ args: args{
+ schedule: serverbackup.BackupSchedule{
+ BackupProperties: &serverbackup.BackupProperties{
+ VolumeIds: &[]string{},
+ },
+ },
+ },
+ },
+ }
+ 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.schedule); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/backup/schedule/list/list.go b/internal/cmd/server/backup/schedule/list/list.go
new file mode 100644
index 000000000..58f8f7ac9
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/list/list.go
@@ -0,0 +1,166 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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-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"
+ 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 (
+ limitFlag = "limit"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ Limit *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all server backup schedules",
+ Long: "Lists all server backup schedules.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all backup schedules for a server with ID "xxx"`,
+ "$ stackit server backup schedule list --server-id xxx"),
+ examples.NewExample(
+ `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, 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 := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("list server backup schedules: %w", err)
+ }
+ schedules := *resp.Items
+ if len(schedules) == 0 {
+ params.Printer.Info("No backup schedules found for server %s\n", serverLabel)
+ return nil
+ }
+
+ // Truncate output
+ if model.Limit != nil && len(schedules) > int(*model.Limit) {
+ schedules = schedules[:*model.Limit]
+ }
+ return outputResult(params.Printer, model.OutputFormat, schedules)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ Limit: limit,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiListBackupSchedulesRequest {
+ req := apiClient.ListBackupSchedules(ctx, model.ProjectId, model.ServerId, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, schedules []serverbackup.BackupSchedule) error {
+ 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 {
+ s := schedules[i]
+
+ backupName := ""
+ retentionPeriod := ""
+ ids := ""
+ if s.BackupProperties != nil {
+ backupName = utils.PtrString(s.BackupProperties.Name)
+ retentionPeriod = utils.PtrString(s.BackupProperties.RetentionPeriod)
+
+ ids = utils.JoinStringPtr(s.BackupProperties.VolumeIds, ",")
+ }
+ table.AddRow(
+ utils.PtrString(s.Id),
+ utils.PtrString(s.Name),
+ utils.PtrString(s.Enabled),
+ utils.PtrString(s.Rrule),
+ backupName,
+ retentionPeriod,
+ ids,
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..9c08b0f5d
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testRegion = "eu01"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ limitFlag: "10",
+ serverIdFlag: testServerId,
+ }
+ 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)),
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiListBackupSchedulesRequest)) serverbackup.ApiListBackupSchedulesRequest {
+ request := testClient.ListBackupSchedules(testCtx, testProjectId, testServerId, 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 serverbackup.ApiListBackupSchedulesRequest
+ }{
+ {
+ 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
+ schedules []serverbackup.BackupSchedule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty backup properties",
+ args: args{
+ outputFormat: "",
+ schedules: []serverbackup.BackupSchedule{
+ {
+ BackupProperties: &serverbackup.BackupProperties{},
+ },
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "output format json",
+ args: args{
+ outputFormat: print.JSONOutputFormat,
+ schedules: []serverbackup.BackupSchedule{},
+ },
+ 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.schedules); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/backup/schedule/schedule.go b/internal/cmd/server/backup/schedule/schedule.go
new file mode 100644
index 000000000..fd93c4cde
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/schedule.go
@@ -0,0 +1,34 @@
+package schedule
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/schedule/create"
+ del "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/schedule/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup/schedule/describe"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "schedule",
+ Short: "Provides functionality for Server Backup Schedule",
+ Long: "Provides functionality for Server Backup Schedule.",
+ 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(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
new file mode 100644
index 000000000..d16db4d63
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/update/update.go
@@ -0,0 +1,182 @@
+package update
+
+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"
+ "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/serverbackup/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ scheduleIdArg = "SCHEDULE_ID"
+
+ backupScheduleNameFlag = "backup-schedule-name"
+ enabledFlag = "enabled"
+ rruleFlag = "rrule"
+ backupNameFlag = "backup-name"
+ backupVolumeIdsFlag = "backup-volume-ids"
+ backupRetentionPeriodFlag = "backup-retention-period"
+ serverIdFlag = "server-id"
+
+ defaultRrule = "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"
+ defaultRetentionPeriod = 14
+ defaultEnabled = true
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ ServerId string
+ BackupScheduleId string
+ BackupScheduleName *string
+ Enabled *bool
+ Rrule *string
+ BackupName *string
+ BackupRetentionPeriod *int64
+ BackupVolumeIds []string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", scheduleIdArg),
+ Short: "Updates a Server Backup Schedule",
+ Long: "Updates a Server Backup Schedule.",
+ Example: examples.Build(
+ examples.NewExample(
+ `Update the retention period of the backup schedule "zzz" of server "xxx"`,
+ "$ stackit server backup schedule update zzz --server-id=xxx --backup-retention-period=20"),
+ examples.NewExample(
+ `Update the backup name of the backup schedule "zzz" of server "xxx"`,
+ "$ stackit server backup schedule update zzz --server-id=xxx --backup-name=newname"),
+ ),
+ Args: args.SingleArg(scheduleIdArg, nil),
+ 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
+ }
+
+ currentBackupSchedule, err := apiClient.GetBackupScheduleExecute(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupScheduleId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get current server backup schedule: %v", err)
+ 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
+ req, err := buildRequest(ctx, model, apiClient, *currentBackupSchedule)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update Server Backup Schedule: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ cmd.Flags().StringP(backupScheduleNameFlag, "n", "", "Backup schedule name")
+ cmd.Flags().StringP(backupNameFlag, "b", "", "Backup name")
+ cmd.Flags().Int64P(backupRetentionPeriodFlag, "d", defaultRetentionPeriod, "Backup retention period (in days)")
+ cmd.Flags().BoolP(enabledFlag, "e", defaultEnabled, "Is the server backup schedule enabled")
+ cmd.Flags().StringP(rruleFlag, "r", defaultRrule, "Backup RRULE (recurrence rule)")
+ cmd.Flags().VarP(flags.UUIDSliceFlag(), backupVolumeIdsFlag, "i", "Backup volume IDs, as comma separated UUID values.")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ scheduleId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ BackupScheduleId: scheduleId,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ BackupRetentionPeriod: flags.FlagToInt64Pointer(p, cmd, backupRetentionPeriodFlag),
+ BackupScheduleName: flags.FlagToStringPointer(p, cmd, backupScheduleNameFlag),
+ BackupName: flags.FlagToStringPointer(p, cmd, backupNameFlag),
+ Rrule: flags.FlagToStringPointer(p, cmd, rruleFlag),
+ Enabled: flags.FlagToBoolPointer(p, cmd, enabledFlag),
+ BackupVolumeIds: flags.FlagToStringSliceValue(p, cmd, backupVolumeIdsFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient, old serverbackup.BackupSchedule) (serverbackup.ApiUpdateBackupScheduleRequest, error) {
+ req := apiClient.UpdateBackupSchedule(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupScheduleId)
+
+ if model.BackupName != nil {
+ old.BackupProperties.Name = model.BackupName
+ }
+ if model.BackupRetentionPeriod != nil {
+ old.BackupProperties.RetentionPeriod = model.BackupRetentionPeriod
+ }
+ if model.BackupVolumeIds != nil {
+ old.BackupProperties.VolumeIds = &model.BackupVolumeIds
+ }
+ if model.Enabled != nil {
+ old.Enabled = model.Enabled
+ }
+ if model.BackupScheduleName != nil {
+ old.Name = model.BackupScheduleName
+ }
+ if model.Rrule != nil {
+ old.Rrule = model.Rrule
+ }
+
+ req = req.UpdateBackupSchedulePayload(serverbackup.UpdateBackupSchedulePayload{
+ Enabled: old.Enabled,
+ Name: old.Name,
+ Rrule: old.Rrule,
+ BackupProperties: old.BackupProperties,
+ })
+ return req, nil
+}
+
+func outputResult(p *print.Printer, outputFormat string, resp serverbackup.BackupSchedule) error {
+ 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
new file mode 100644
index 000000000..fc42794a0
--- /dev/null
+++ b/internal/cmd/server/backup/schedule/update/update_test.go
@@ -0,0 +1,308 @@
+package update
+
+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/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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+var testBackupScheduleId = "5"
+var testRegion = "eu01"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testBackupScheduleId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ backupScheduleNameFlag: "example-backup-schedule-name",
+ enabledFlag: "true",
+ rruleFlag: defaultRrule,
+ backupNameFlag: "example-backup-name",
+ backupRetentionPeriodFlag: "14",
+ backupVolumeIdsFlag: testVolumeId,
+ }
+ 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,
+ },
+ BackupScheduleId: testBackupScheduleId,
+ ServerId: testServerId,
+ BackupScheduleName: utils.Ptr("example-backup-schedule-name"),
+ Enabled: utils.Ptr(defaultEnabled),
+ Rrule: utils.Ptr(defaultRrule),
+ BackupName: utils.Ptr("example-backup-name"),
+ BackupRetentionPeriod: utils.Ptr(int64(14)),
+ BackupVolumeIds: []string{testVolumeId},
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureBackupSchedule(mods ...func(schedule *serverbackup.BackupSchedule)) *serverbackup.BackupSchedule {
+ id, _ := strconv.ParseInt(testBackupScheduleId, 10, 64)
+ schedule := &serverbackup.BackupSchedule{
+ Name: utils.Ptr("example-backup-schedule-name"),
+ Id: utils.Ptr(id),
+ Enabled: utils.Ptr(defaultEnabled),
+ Rrule: utils.Ptr(defaultRrule),
+ BackupProperties: &serverbackup.BackupProperties{
+ Name: utils.Ptr("example-backup-name"),
+ RetentionPeriod: utils.Ptr(int64(14)),
+ VolumeIds: utils.Ptr([]string{testVolumeId}),
+ },
+ }
+ for _, mod := range mods {
+ mod(schedule)
+ }
+ return schedule
+}
+
+func fixturePayload(mods ...func(payload *serverbackup.UpdateBackupSchedulePayload)) serverbackup.UpdateBackupSchedulePayload {
+ payload := serverbackup.UpdateBackupSchedulePayload{
+ Name: utils.Ptr("example-backup-schedule-name"),
+ Enabled: utils.Ptr(defaultEnabled),
+ Rrule: utils.Ptr("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"),
+ BackupProperties: &serverbackup.BackupProperties{
+ Name: utils.Ptr("example-backup-name"),
+ RetentionPeriod: utils.Ptr(int64(14)),
+ VolumeIds: utils.Ptr([]string{testVolumeId}),
+ },
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiUpdateBackupScheduleRequest)) serverbackup.ApiUpdateBackupScheduleRequest {
+ request := testClient.UpdateBackupSchedule(testCtx, testProjectId, testServerId, testRegion, testBackupScheduleId)
+ request = request.UpdateBackupSchedulePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "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: "backup schedule id invalid 1",
+ argValues: []string{""},
+ 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.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 flag groups: %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 serverbackup.ApiUpdateBackupScheduleRequest
+ isValid bool
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ isValid: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient, *fixtureBackupSchedule())
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error building request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ resp serverbackup.BackupSchedule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/backup/volume-backup/delete/delete.go b/internal/cmd/server/backup/volume-backup/delete/delete.go
new file mode 100644
index 000000000..1a7241bba
--- /dev/null
+++ b/internal/cmd/server/backup/volume-backup/delete/delete.go
@@ -0,0 +1,110 @@
+package delete
+
+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/services/serverbackup/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ volumeBackupIdArg = "VOLUME_BACKUP_ID"
+ serverIdFlag = "server-id"
+ backupIdFlag = "backup-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ BackupId string
+ VolumeId string
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", volumeBackupIdArg),
+ Short: "Deletes a Server Volume Backup.",
+ Long: "Deletes a Server Volume Backup. Operation always is async.",
+ Args: args.SingleArg(volumeBackupIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete a Server Volume Backup with ID "xxx" for server "zzz" and backup "bbb"`,
+ "$ stackit server backup volume-backup delete xxx --server-id=zzz --backup-id=bbb"),
+ ),
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete Server Volume Backup: %w", err)
+ }
+
+ params.Printer.Info("Triggered deletion of server volume backup %q\n", model.VolumeId)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().VarP(flags.UUIDFlag(), backupIdFlag, "b", "Backup ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ VolumeId: volumeId,
+ BackupId: flags.FlagToStringValue(p, cmd, backupIdFlag),
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiDeleteVolumeBackupRequest {
+ req := apiClient.DeleteVolumeBackup(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupId, model.VolumeId)
+ return req
+}
diff --git a/internal/cmd/server/backup/volume-backup/delete/delete_test.go b/internal/cmd/server/backup/volume-backup/delete/delete_test.go
new file mode 100644
index 000000000..0a1da5857
--- /dev/null
+++ b/internal/cmd/server/backup/volume-backup/delete/delete_test.go
@@ -0,0 +1,166 @@
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testBackupId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+var testRegion = "eu01"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ backupIdFlag: testBackupId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ VolumeId: testVolumeId,
+ BackupId: testBackupId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiDeleteVolumeBackupRequest)) serverbackup.ApiDeleteVolumeBackupRequest {
+ request := testClient.DeleteVolumeBackup(testCtx, testProjectId, testServerId, testRegion, testBackupId, testVolumeId)
+ 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,
+ },
+ }
+
+ 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 serverbackup.ApiDeleteVolumeBackupRequest
+ }{
+ {
+ 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/backup/volume-backup/restore/restore.go b/internal/cmd/server/backup/volume-backup/restore/restore.go
new file mode 100644
index 000000000..343f4ade8
--- /dev/null
+++ b/internal/cmd/server/backup/volume-backup/restore/restore.go
@@ -0,0 +1,118 @@
+package restore
+
+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/services/serverbackup/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+const (
+ volumeBackupIdArg = "VOLUME_BACKUP_ID"
+ serverIdFlag = "server-id"
+ backupIdFlag = "backup-id"
+ restoreVolumeIdFlag = "restore-volume-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeBackupId string
+ BackupId string
+ ServerId string
+ RestoreVolumeId string
+}
+
+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.",
+ Long: "Restore a Server Volume Backup to a volume. Operation always is async.",
+ Args: args.SingleArg(volumeBackupIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Restore a Server Volume Backup with ID "xxx" for server "zzz" and backup "bbb" to volume "rrr"`,
+ "$ stackit server backup volume-backup restore xxx --server-id=zzz --backup-id=bbb --restore-volume-id=rrr"),
+ ),
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("restore Server Volume Backup: %w", err)
+ }
+
+ params.Printer.Info("Triggered restoring of server volume backup %q\n", model.VolumeBackupId)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().VarP(flags.UUIDFlag(), backupIdFlag, "b", "Backup ID")
+ cmd.Flags().VarP(flags.UUIDFlag(), restoreVolumeIdFlag, "r", "Restore Volume ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeBackupId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ VolumeBackupId: volumeBackupId,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ BackupId: flags.FlagToStringValue(p, cmd, backupIdFlag),
+ RestoreVolumeId: flags.FlagToStringValue(p, cmd, restoreVolumeIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverbackup.APIClient) serverbackup.ApiRestoreVolumeBackupRequest {
+ req := apiClient.RestoreVolumeBackup(ctx, model.ProjectId, model.ServerId, model.Region, model.BackupId, model.VolumeBackupId)
+ payload := serverbackup.RestoreVolumeBackupPayload{
+ RestoreVolumeId: &model.RestoreVolumeId,
+ }
+ req = req.RestoreVolumeBackupPayload(payload)
+ return req
+}
diff --git a/internal/cmd/server/backup/volume-backup/restore/restore_test.go b/internal/cmd/server/backup/volume-backup/restore/restore_test.go
new file mode 100644
index 000000000..f329161db
--- /dev/null
+++ b/internal/cmd/server/backup/volume-backup/restore/restore_test.go
@@ -0,0 +1,172 @@
+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/serverbackup"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverbackup.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testBackupId = uuid.NewString()
+var testVolumeBackupId = uuid.NewString()
+var testRestoreVolumeId = uuid.NewString()
+var testRegion = "eu01"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeBackupId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ backupIdFlag: testBackupId,
+ restoreVolumeIdFlag: testRestoreVolumeId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ BackupId: testBackupId,
+ VolumeBackupId: testVolumeBackupId,
+ RestoreVolumeId: testRestoreVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverbackup.ApiRestoreVolumeBackupRequest)) serverbackup.ApiRestoreVolumeBackupRequest {
+ request := testClient.RestoreVolumeBackup(testCtx, testProjectId, testServerId, testRegion, testBackupId, testVolumeBackupId)
+ request = request.RestoreVolumeBackupPayload(serverbackup.RestoreVolumeBackupPayload{
+ RestoreVolumeId: &testRestoreVolumeId,
+ })
+ 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,
+ },
+ }
+
+ 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 serverbackup.ApiRestoreVolumeBackupRequest
+ }{
+ {
+ 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/backup/volume-backup/volumebackup.go b/internal/cmd/server/backup/volume-backup/volumebackup.go
new file mode 100644
index 000000000..5bdf0f72d
--- /dev/null
+++ b/internal/cmd/server/backup/volume-backup/volumebackup.go
@@ -0,0 +1,28 @@
+package volumebackup
+
+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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "volume-backup",
+ Short: "Provides functionality for Server Backup Volume Backups",
+ Long: "Provides functionality for Server Backup Volume Backups.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+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
new file mode 100644
index 000000000..ccd8978fd
--- /dev/null
+++ b/internal/cmd/server/command/command.go
@@ -0,0 +1,32 @@
+package command
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/command/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/command/describe"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "command",
+ Short: "Provides functionality for Server Command",
+ Long: "Provides functionality for Server Command.",
+ 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(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
new file mode 100644
index 000000000..1561ab3b5
--- /dev/null
+++ b/internal/cmd/server/command/create/create.go
@@ -0,0 +1,149 @@
+package create
+
+import (
+ "context"
+ "fmt"
+
+ "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-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"
+ 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 (
+ serverIdFlag = "server-id"
+ commandTemplateNameFlag = "template-name"
+ paramsFlag = "params"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ ServerId string
+ CommandTemplateName string
+ Params *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a Server Command",
+ Long: "Creates a Server Command.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a server command for server with ID "xxx", template name "RunShellScript" and a script from a file (using the @{...} format)`,
+ `$ stackit server command create --server-id xxx --template-name=RunShellScript --params script='@{/path/to/script.sh}'`),
+ examples.NewExample(
+ `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, 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 := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create Server Command: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().StringP(commandTemplateNameFlag, "n", "", "Template name")
+ cmd.Flags().StringToStringP(paramsFlag, "r", nil, "Params can be provided with the format key=value and the flag can be used multiple times to provide a list of labels")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag, commandTemplateNameFlag)
+ 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),
+ CommandTemplateName: flags.FlagToStringValue(p, cmd, commandTemplateNameFlag),
+ Params: flags.FlagToStringToStringPointer(p, cmd, paramsFlag),
+ }
+ parsedParams, err := runcommandUtils.ParseScriptParams(*model.Params)
+ if err != nil {
+ return nil, &cliErr.FlagValidationError{
+ Flag: paramsFlag,
+ Details: err.Error(),
+ }
+ }
+ model.Params = &parsedParams
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *runcommand.APIClient) (runcommand.ApiCreateCommandRequest, error) {
+ req := apiClient.CreateCommand(ctx, model.ProjectId, model.ServerId, model.Region)
+ req = req.CreateCommandPayload(runcommand.CreateCommandPayload{
+ CommandTemplateName: &model.CommandTemplateName,
+ Parameters: model.Params,
+ })
+ return req, nil
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel string, resp runcommand.NewCommandResponse) error {
+ 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
new file mode 100644
index 000000000..5b19bf508
--- /dev/null
+++ b/internal/cmd/server/command/create/create_test.go
@@ -0,0 +1,199 @@
+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/runcommand"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &runcommand.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+const (
+ testRegion = "eu02"
+)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ serverIdFlag: testServerId,
+ commandTemplateNameFlag: "RunShellScript",
+ paramsFlag: `script='echo hello'`,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ CommandTemplateName: "RunShellScript",
+ Params: &map[string]string{"script": "'echo hello'"},
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *runcommand.ApiCreateCommandRequest)) runcommand.ApiCreateCommandRequest {
+ request := testClient.CreateCommand(testCtx, testProjectId, testServerId, testRegion)
+ request = request.CreateCommandPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *runcommand.CreateCommandPayload)) runcommand.CreateCommandPayload {
+ payload := runcommand.CreateCommandPayload{
+ CommandTemplateName: utils.Ptr("RunShellScript"),
+ Parameters: &map[string]string{"script": "'echo hello'"},
+ }
+ 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: "with defaults",
+ 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,
+ },
+ }
+
+ 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 runcommand.ApiCreateCommandRequest
+ isValid bool
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error building request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat, serverLabel string
+ resp runcommand.NewCommandResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serverLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/command/describe/describe.go b/internal/cmd/server/command/describe/describe.go
new file mode 100644
index 000000000..8ca9b6aa3
--- /dev/null
+++ b/internal/cmd/server/command/describe/describe.go
@@ -0,0 +1,131 @@
+package describe
+
+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/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 (
+ commandIdArg = "COMMAND_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ CommandId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", commandIdArg),
+ Short: "Shows details of a Server Command",
+ Long: "Shows details of a Server Command.",
+ Args: args.SingleArg(commandIdArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of a Server Command with ID "xxx" for server with ID "yyy"`,
+ "$ stackit server command describe xxx --server-id=yyy"),
+ examples.NewExample(
+ `Get details of a Server Command with ID "xxx" for server with ID "yyy" in JSON format`,
+ "$ stackit server command describe xxx --server-id=yyy --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 server command: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ commandId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ CommandId: commandId,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *runcommand.APIClient) runcommand.ApiGetCommandRequest {
+ req := apiClient.GetCommand(ctx, model.ProjectId, model.Region, model.ServerId, model.CommandId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, command runcommand.CommandDetails) error {
+ return p.OutputResult(outputFormat, command, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(command.Id))
+ table.AddSeparator()
+ table.AddRow("COMMAND TEMPLATE NAME", utils.PtrString(command.CommandTemplateName))
+ table.AddSeparator()
+ table.AddRow("COMMAND TEMPLATE TITLE", utils.PtrString(command.CommandTemplateTitle))
+ table.AddSeparator()
+ table.AddRow("STATUS", utils.PtrString(command.Status))
+ table.AddSeparator()
+ table.AddRow("STARTED AT", utils.PtrString(command.StartedAt))
+ table.AddSeparator()
+ table.AddRow("FINISHED AT", utils.PtrString(command.FinishedAt))
+ table.AddSeparator()
+ table.AddRow("EXIT CODE", utils.PtrString(command.ExitCode))
+ table.AddSeparator()
+ table.AddRow("COMMAND SCRIPT", utils.PtrString(command.Script))
+ table.AddSeparator()
+ table.AddRow("COMMAND OUTPUT", utils.PtrString(command.Output))
+ table.AddSeparator()
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/server/command/describe/describe_test.go b/internal/cmd/server/command/describe/describe_test.go
new file mode 100644
index 000000000..5ad83715a
--- /dev/null
+++ b/internal/cmd/server/command/describe/describe_test.go
@@ -0,0 +1,228 @@
+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/runcommand"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &runcommand.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+const (
+ testRegion = "eu02"
+ testCommandId = "5"
+)
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testCommandId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ CommandId: testCommandId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *runcommand.ApiGetCommandRequest)) runcommand.ApiGetCommandRequest {
+ request := testClient.GetCommand(testCtx, testProjectId, testRegion, testServerId, testCommandId)
+ 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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "command id invalid 1",
+ argValues: []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.argValues, tt.flagValues, tt.isValid)
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ isValid bool
+ expectedRequest runcommand.ApiGetCommandRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ command runcommand.CommandDetails
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.command); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/command/list/list.go b/internal/cmd/server/command/list/list.go
new file mode 100644
index 000000000..e5607abd0
--- /dev/null
+++ b/internal/cmd/server/command/list/list.go
@@ -0,0 +1,154 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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-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"
+ 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 (
+ limitFlag = "limit"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ Limit *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all server commands",
+ Long: "Lists all server commands.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all commands for a server with ID "xxx"`,
+ "$ stackit server command list --server-id xxx"),
+ examples.NewExample(
+ `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, 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 := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("list server commands: %w", err)
+ }
+ if commands := resp.Items; commands == nil || len(*commands) == 0 {
+ params.Printer.Info("No commands found for server %s\n", serverLabel)
+ return nil
+ }
+ commands := *resp.Items
+ // Truncate output
+ if model.Limit != nil && len(commands) > int(*model.Limit) {
+ commands = commands[:*model.Limit]
+ }
+ return outputResult(params.Printer, model.OutputFormat, commands)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ Limit: limit,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *runcommand.APIClient) runcommand.ApiListCommandsRequest {
+ req := apiClient.ListCommands(ctx, model.ProjectId, model.ServerId, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, commands []runcommand.Commands) error {
+ 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 {
+ s := commands[i]
+ table.AddRow(
+ utils.PtrString(s.Id),
+ utils.PtrString(s.CommandTemplateName),
+ utils.PtrString(s.CommandTemplateTitle),
+ utils.PtrString(s.Status),
+ utils.PtrString(s.StartedAt),
+ utils.PtrString(s.FinishedAt),
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..bb806598d
--- /dev/null
+++ b/internal/cmd/server/command/list/list_test.go
@@ -0,0 +1,184 @@
+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/runcommand"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &runcommand.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+const (
+ 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",
+ serverIdFlag: testServerId,
+ }
+ 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)),
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *runcommand.ApiListCommandsRequest)) runcommand.ApiListCommandsRequest {
+ request := testClient.ListCommands(testCtx, testProjectId, testServerId, 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 runcommand.ApiListCommandsRequest
+ }{
+ {
+ 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
+ commands []runcommand.Commands
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.commands); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/command/template/describe/describe.go b/internal/cmd/server/command/template/describe/describe.go
new file mode 100644
index 000000000..a70ee5ae2
--- /dev/null
+++ b/internal/cmd/server/command/template/describe/describe.go
@@ -0,0 +1,129 @@
+package describe
+
+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/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 (
+ commandTemplateNameArg = "COMMAND_TEMPLATE_NAME"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ CommandTemplateName string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", commandTemplateNameArg),
+ Short: "Shows details of a Server Command Template",
+ Long: "Shows details of a Server Command Template.",
+ Args: args.SingleArg(commandTemplateNameArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of a Server Command Template with name "RunShellScript" for server with ID "xxx"`,
+ "$ stackit server command template describe RunShellScript --server-id=xxx"),
+ examples.NewExample(
+ `Get details of a Server Command Template with name "RunShellScript" for server with ID "xxx" in JSON format`,
+ "$ stackit server command template describe RunShellScript --server-id=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 server command template: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ commandTemplateName := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ CommandTemplateName: commandTemplateName,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *runcommand.APIClient) runcommand.ApiGetCommandTemplateRequest {
+ req := apiClient.GetCommandTemplate(ctx, model.ProjectId, model.ServerId, model.CommandTemplateName, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, commandTemplate runcommand.CommandTemplateSchema) error {
+ return p.OutputResult(outputFormat, commandTemplate, func() error {
+ table := tables.NewTable()
+ table.AddRow("NAME", utils.PtrString(commandTemplate.Name))
+ table.AddSeparator()
+ table.AddRow("TITLE", utils.PtrString(commandTemplate.Title))
+ table.AddSeparator()
+ table.AddRow("DESCRIPTION", utils.PtrString(commandTemplate.Description))
+ table.AddSeparator()
+ if commandTemplate.OsType != nil {
+ table.AddRow("OS TYPE", utils.JoinStringPtr(commandTemplate.OsType, "\n"))
+ table.AddSeparator()
+ }
+ if commandTemplate.ParameterSchema != nil {
+ table.AddRow("PARAMS", *commandTemplate.ParameterSchema)
+ } else {
+ table.AddRow("PARAMS", "")
+ }
+ table.AddSeparator()
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/server/command/template/describe/describe_test.go b/internal/cmd/server/command/template/describe/describe_test.go
new file mode 100644
index 000000000..aaa90096a
--- /dev/null
+++ b/internal/cmd/server/command/template/describe/describe_test.go
@@ -0,0 +1,228 @@
+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/runcommand"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &runcommand.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+const (
+ testCommandTemplateName = "RunShellScript"
+ testRegion = "eu02"
+)
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testCommandTemplateName,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ CommandTemplateName: testCommandTemplateName,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *runcommand.ApiGetCommandTemplateRequest)) runcommand.ApiGetCommandTemplateRequest {
+ request := testClient.GetCommandTemplate(testCtx, testProjectId, testServerId, testCommandTemplateName, 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",
+ 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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "command template name invalid 1",
+ argValues: []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.argValues, tt.flagValues, tt.isValid)
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ isValid bool
+ expectedRequest runcommand.ApiGetCommandTemplateRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ commandTemplate runcommand.CommandTemplateSchema
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.commandTemplate); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/command/template/list/list.go b/internal/cmd/server/command/template/list/list.go
new file mode 100644
index 000000000..723772b45
--- /dev/null
+++ b/internal/cmd/server/command/template/list/list.go
@@ -0,0 +1,137 @@
+package list
+
+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/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 (
+ limitFlag = "limit"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all server command templates",
+ Long: "Lists all server command templates.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all command templates`,
+ "$ stackit server command template list"),
+ examples.NewExample(
+ `List all commands templates in JSON format`,
+ "$ stackit server command template list --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("list server command templates: %w", err)
+ }
+ if templates := resp.Items; templates == nil || len(*templates) == 0 {
+ params.Printer.Info("No commands templates found\n")
+ return nil
+ }
+ templates := *resp.Items
+
+ // Truncate output
+ if model.Limit != nil && len(templates) > int(*model.Limit) {
+ templates = templates[:*model.Limit]
+ }
+ return outputResult(params.Printer, model.OutputFormat, templates)
+ },
+ }
+ 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, _ *inputModel, apiClient *runcommand.APIClient) runcommand.ApiListCommandTemplatesRequest {
+ req := apiClient.ListCommandTemplates(ctx)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, templates []runcommand.CommandTemplate) error {
+ return p.OutputResult(outputFormat, templates, func() error {
+ table := tables.NewTable()
+ table.SetHeader("NAME", "OS TYPE", "TITLE")
+ for i := range templates {
+ s := templates[i]
+
+ var osType string
+ if s.OsType != nil && len(*s.OsType) > 0 {
+ osType = utils.JoinStringPtr(s.OsType, ",")
+ }
+
+ table.AddRow(
+ utils.PtrString(s.Name),
+ osType,
+ utils.PtrString(s.Title),
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..df57cce22
--- /dev/null
+++ b/internal/cmd/server/command/template/list/list_test.go
@@ -0,0 +1,183 @@
+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/runcommand"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &runcommand.APIClient{}
+var testProjectId = uuid.NewString()
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ limitFlag: "10",
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ Limit: utils.Ptr(int64(10)),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *runcommand.ApiListCommandTemplatesRequest)) runcommand.ApiListCommandTemplatesRequest {
+ request := testClient.ListCommandTemplates(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, 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 runcommand.ApiListCommandTemplatesRequest
+ }{
+ {
+ 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
+ templates []runcommand.CommandTemplate
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty command template",
+ args: args{
+ templates: []runcommand.CommandTemplate{
+ {},
+ },
+ },
+ },
+ }
+ 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.templates); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/command/template/template.go b/internal/cmd/server/command/template/template.go
new file mode 100644
index 000000000..5607fa5c4
--- /dev/null
+++ b/internal/cmd/server/command/template/template.go
@@ -0,0 +1,28 @@
+package template
+
+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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "template",
+ Short: "Provides functionality for Server Command Template",
+ Long: "Provides functionality for Server Command Template.",
+ 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))
+}
diff --git a/internal/cmd/server/console/console.go b/internal/cmd/server/console/console.go
new file mode 100644
index 000000000..23e7f5c10
--- /dev/null
+++ b/internal/cmd/server/console/console.go
@@ -0,0 +1,118 @@
+package console
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+
+ "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/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"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("console %s", serverIdArg),
+ Short: "Gets a URL for server remote console",
+ Long: "Gets a URL for server remote console.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get a URL for the server remote console with server ID "xxx"`,
+ "$ stackit server console xxx",
+ ),
+ examples.NewExample(
+ `Get a URL for the server remote console with server ID "xxx" in JSON format`,
+ "$ stackit server console 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
+ }
+
+ 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
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("server console: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ }
+
+ 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.Region, model.ServerId)
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel string, serverUrl iaas.ServerConsoleUrl) error {
+ return p.OutputResult(outputFormat, serverUrl, func() error {
+ if _, ok := serverUrl.GetUrlOk(); !ok {
+ return fmt.Errorf("server url is nil")
+ }
+ // unescape url in order to get rid of e.g. %40
+ unescapedURL, err := url.PathUnescape(serverUrl.GetUrl())
+ if err != nil {
+ return fmt.Errorf("unescape url: %w", err)
+ }
+
+ 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
new file mode 100644
index 000000000..6fa46ba1b
--- /dev/null
+++ b/internal/cmd/server/console/console_test.go
@@ -0,0 +1,205 @@
+package console
+
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetServerConsoleRequest)) iaas.ApiGetServerConsoleRequest {
+ request := testClient.GetServerConsole(testCtx, testProjectId, testRegion, testServerId)
+ 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: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "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.ApiGetServerConsoleRequest
+ }{
+ {
+ 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, serverLabel string
+ serverUrl iaas.ServerConsoleUrl
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "set server url",
+ args: args{
+ serverUrl: iaas.ServerConsoleUrl{
+ Url: utils.Ptr(""),
+ },
+ },
+ 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.serverLabel, tt.args.serverUrl); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/create/create.go b/internal/cmd/server/create/create.go
new file mode 100644
index 000000000..a93efcbf7
--- /dev/null
+++ b/internal/cmd/server/create/create.go
@@ -0,0 +1,333 @@
+package create
+
+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"
+ "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/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 (
+ nameFlag = "name"
+ machineTypeFlag = "machine-type"
+ affinityGroupFlag = "affinity-group"
+ availabilityZoneFlag = "availability-zone"
+ bootVolumeSourceIdFlag = "boot-volume-source-id"
+ bootVolumeSourceTypeFlag = "boot-volume-source-type"
+ bootVolumeSizeFlag = "boot-volume-size"
+ bootVolumePerformanceClassFlag = "boot-volume-performance-class"
+ bootVolumeDeleteOnTerminationFlag = "boot-volume-delete-on-termination"
+ imageIdFlag = "image-id"
+ keypairNameFlag = "keypair-name"
+ labelFlag = "labels"
+ networkIdFlag = "network-id"
+ networkInterfaceIdsFlag = "network-interface-ids"
+ securityGroupsFlag = "security-groups"
+ serviceAccountEmailsFlag = "service-account-emails"
+ userDataFlag = "user-data"
+ volumesFlag = "volumes"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Name *string
+ MachineType *string
+ AffinityGroup *string
+ AvailabilityZone *string
+ BootVolumeSourceId *string
+ BootVolumeSourceType *string
+ BootVolumeSize *int64
+ BootVolumePerformanceClass *string
+ BootVolumeDeleteOnTermination *bool
+ ImageId *string
+ KeypairName *string
+ Labels *map[string]string
+ NetworkId *string
+ NetworkInterfaceIds *[]string
+ SecurityGroups *[]string
+ ServiceAccountMails *[]string
+ UserData *string
+ Volumes *[]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a server",
+ Long: "Creates a server.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a server from an image with id xxx`,
+ `$ stackit server create --machine-type t1.1 --name server1 --image-id xxx`,
+ ),
+ examples.NewExample(
+ `Create a server with labels from an image with id xxx`,
+ `$ stackit server create --machine-type t1.1 --name server1 --image-id xxx --labels key=value,foo=bar`,
+ ),
+ examples.NewExample(
+ `Create a server with a boot volume`,
+ `$ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64`,
+ ),
+ examples.NewExample(
+ `Create a server with a boot volume from an existing volume`,
+ `$ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type volume`,
+ ),
+ examples.NewExample(
+ `Create a server with a keypair`,
+ `$ stackit server create --machine-type t1.1 --name server1 --image-id xxx --keypair-name example`,
+ ),
+ examples.NewExample(
+ `Create a server with a network`,
+ `$ stackit server create --machine-type t1.1 --name server1 --image-id xxx --network-id yyy`,
+ ),
+ examples.NewExample(
+ `Create a server with a network interface`,
+ `$ stackit server create --machine-type t1.1 --name server1 --boot-volume-source-id xxx --boot-volume-source-type image --boot-volume-size 64 --network-interface-ids yyy`,
+ ),
+ examples.NewExample(
+ `Create a server with an attached volume`,
+ `$ 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`,
+ ),
+ 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`,
+ ),
+ ),
+ 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 server 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 server : %w", err)
+ }
+ serverId := *resp.Id
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Creating server")
+ _, err = wait.CreateServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, serverId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for server creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, projectLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+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/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")
+ cmd.Flags().String(bootVolumeSourceTypeFlag, "", "Type of the source object of boot volume. It can be either 'image' or 'volume'")
+ cmd.Flags().Int64(bootVolumeSizeFlag, 0, "The size of the boot volume in GB. Must be provided when 'boot-volume-source-type' is 'image'")
+ cmd.Flags().String(bootVolumePerformanceClassFlag, "", "Boot volume performance class")
+ cmd.Flags().Bool(bootVolumeDeleteOnTerminationFlag, false, "Delete the volume during the termination of the server. Defaults to false")
+ cmd.Flags().String(imageIdFlag, "", "The image ID to be used for an ephemeral disk on the server. Either 'image-id' or 'boot-volume-...' flags are required")
+ cmd.Flags().String(keypairNameFlag, "", "The name of the SSH keypair used during the server creation")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...'")
+ cmd.Flags().String(networkIdFlag, "", "ID of the network for the initial networking setup for the server creation")
+ cmd.Flags().StringSlice(networkInterfaceIdsFlag, []string{}, "List of network interface IDs for the initial networking setup for the server creation")
+ cmd.Flags().StringSlice(securityGroupsFlag, []string{}, "The initial security groups for the server creation")
+ cmd.Flags().StringSlice(serviceAccountEmailsFlag, []string{}, "List of the service account mails")
+ cmd.Flags().Var(flags.ReadFromFileFlag(), userDataFlag, "User data that is passed via cloud-init to the server")
+ cmd.Flags().StringSlice(volumesFlag, []string{}, "The list of volumes attached to the server")
+
+ err := flags.MarkFlagsRequired(cmd, nameFlag, machineTypeFlag)
+ 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, _ []string) (*inputModel, error) {
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ bootVolumeSourceId := flags.FlagToStringPointer(p, cmd, bootVolumeSourceIdFlag)
+ bootVolumeSourceType := flags.FlagToStringPointer(p, cmd, bootVolumeSourceTypeFlag)
+ bootVolumeSize := flags.FlagToInt64Pointer(p, cmd, bootVolumeSizeFlag)
+ imageId := flags.FlagToStringPointer(p, cmd, imageIdFlag)
+
+ if imageId == nil && bootVolumeSourceId == nil && bootVolumeSourceType == nil {
+ return nil, &cliErr.ServerCreateMissingFlagsError{
+ Cmd: cmd,
+ }
+ }
+
+ if imageId == nil {
+ err := flags.MarkFlagsRequired(cmd, bootVolumeSourceIdFlag, bootVolumeSourceTypeFlag)
+ cobra.CheckErr(err)
+ }
+
+ if bootVolumeSourceId != nil && bootVolumeSourceType == nil {
+ err := cmd.MarkFlagRequired(bootVolumeSourceTypeFlag)
+ cobra.CheckErr(err)
+
+ return nil, &cliErr.ServerCreateMissingVolumeTypeError{
+ Cmd: cmd,
+ }
+ }
+
+ if bootVolumeSourceType != nil {
+ if bootVolumeSourceId == nil {
+ err := cmd.MarkFlagRequired(bootVolumeSourceIdFlag)
+ cobra.CheckErr(err)
+
+ return nil, &cliErr.ServerCreateMissingVolumeIdError{
+ Cmd: cmd,
+ }
+ }
+
+ if *bootVolumeSourceType == "image" && bootVolumeSize == nil {
+ err := cmd.MarkFlagRequired(bootVolumeSizeFlag)
+ cobra.CheckErr(err)
+ return nil, &cliErr.ServerCreateError{
+ Cmd: cmd,
+ }
+ }
+ }
+
+ if bootVolumeSourceId == nil && bootVolumeSourceType == nil {
+ err := cmd.MarkFlagRequired(imageIdFlag)
+ cobra.CheckErr(err)
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ MachineType: flags.FlagToStringPointer(p, cmd, machineTypeFlag),
+ AffinityGroup: flags.FlagToStringPointer(p, cmd, affinityGroupFlag),
+ AvailabilityZone: flags.FlagToStringPointer(p, cmd, availabilityZoneFlag),
+ BootVolumeSourceId: flags.FlagToStringPointer(p, cmd, bootVolumeSourceIdFlag),
+ BootVolumeSourceType: flags.FlagToStringPointer(p, cmd, bootVolumeSourceTypeFlag),
+ BootVolumeSize: flags.FlagToInt64Pointer(p, cmd, bootVolumeSizeFlag),
+ BootVolumePerformanceClass: flags.FlagToStringPointer(p, cmd, bootVolumePerformanceClassFlag),
+ BootVolumeDeleteOnTermination: flags.FlagToBoolPointer(p, cmd, bootVolumeDeleteOnTerminationFlag),
+ ImageId: flags.FlagToStringPointer(p, cmd, imageIdFlag),
+ KeypairName: flags.FlagToStringPointer(p, cmd, keypairNameFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag),
+ NetworkInterfaceIds: flags.FlagToStringSlicePointer(p, cmd, networkInterfaceIdsFlag),
+ SecurityGroups: flags.FlagToStringSlicePointer(p, cmd, securityGroupsFlag),
+ ServiceAccountMails: flags.FlagToStringSlicePointer(p, cmd, serviceAccountEmailsFlag),
+ UserData: flags.FlagToStringPointer(p, cmd, userDataFlag),
+ Volumes: flags.FlagToStringSlicePointer(p, cmd, volumesFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateServerRequest {
+ req := apiClient.CreateServer(ctx, model.ProjectId, model.Region)
+
+ var userData *[]byte
+ if model.UserData != nil {
+ userData = utils.Ptr([]byte(*model.UserData))
+ }
+
+ payload := iaas.CreateServerPayload{
+ Name: model.Name,
+ MachineType: model.MachineType,
+ AffinityGroup: model.AffinityGroup,
+ AvailabilityZone: model.AvailabilityZone,
+
+ ImageId: model.ImageId,
+ KeypairName: model.KeypairName,
+ SecurityGroups: model.SecurityGroups,
+ ServiceAccountMails: model.ServiceAccountMails,
+ UserData: userData,
+ Volumes: model.Volumes,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+
+ if model.BootVolumePerformanceClass != nil || model.BootVolumeSize != nil || model.BootVolumeDeleteOnTermination != nil || model.BootVolumeSourceId != nil || model.BootVolumeSourceType != nil {
+ payload.BootVolume = &iaas.ServerBootVolume{
+ PerformanceClass: model.BootVolumePerformanceClass,
+ Size: model.BootVolumeSize,
+ DeleteOnTermination: model.BootVolumeDeleteOnTermination,
+ Source: &iaas.BootVolumeSource{
+ Id: model.BootVolumeSourceId,
+ Type: model.BootVolumeSourceType,
+ },
+ }
+ }
+
+ if model.NetworkInterfaceIds != nil || model.NetworkId != nil {
+ payload.Networking = &iaas.CreateServerPayloadAllOfNetworking{}
+
+ if model.NetworkInterfaceIds != nil {
+ payload.Networking.CreateServerNetworkingWithNics = &iaas.CreateServerNetworkingWithNics{
+ NicIds: model.NetworkInterfaceIds,
+ }
+ }
+ if model.NetworkId != nil {
+ payload.Networking.CreateServerNetworking = &iaas.CreateServerNetworking{
+ NetworkId: model.NetworkId,
+ }
+ }
+ }
+
+ return req.CreateServerPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, projectLabel string, server *iaas.Server) error {
+ if server == nil {
+ return fmt.Errorf("server response is empty")
+ }
+ return p.OutputResult(outputFormat, server, func() error {
+ 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
new file mode 100644
index 000000000..521b80922
--- /dev/null
+++ b/internal/cmd/server/create/create_test.go
@@ -0,0 +1,415 @@
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testSourceId = uuid.NewString()
+var testImageId = uuid.NewString()
+var testNetworkId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ availabilityZoneFlag: "eu01-1",
+ nameFlag: "test-server-name",
+ machineTypeFlag: "t1.1",
+ affinityGroupFlag: "test-affinity-group",
+ labelFlag: "key=value",
+ bootVolumePerformanceClassFlag: "test-perf-class",
+ bootVolumeSizeFlag: "5",
+ bootVolumeSourceIdFlag: testSourceId,
+ bootVolumeSourceTypeFlag: "test-source-type",
+ bootVolumeDeleteOnTerminationFlag: "false",
+ keypairNameFlag: "test-keypair-name",
+ networkIdFlag: testNetworkId,
+ securityGroupsFlag: "test-security-groups",
+ serviceAccountEmailsFlag: "test-service-account",
+ userDataFlag: "test-user-data",
+ volumesFlag: testVolumeId,
+ }
+ 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,
+ },
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ Name: utils.Ptr("test-server-name"),
+ MachineType: utils.Ptr("t1.1"),
+ AffinityGroup: utils.Ptr("test-affinity-group"),
+ BootVolumePerformanceClass: utils.Ptr("test-perf-class"),
+ BootVolumeSize: utils.Ptr(int64(5)),
+ BootVolumeSourceId: utils.Ptr(testSourceId),
+ BootVolumeSourceType: utils.Ptr("test-source-type"),
+ BootVolumeDeleteOnTermination: utils.Ptr(false),
+ KeypairName: utils.Ptr("test-keypair-name"),
+ NetworkId: utils.Ptr(testNetworkId),
+ SecurityGroups: utils.Ptr([]string{"test-security-groups"}),
+ ServiceAccountMails: utils.Ptr([]string{"test-service-account"}),
+ UserData: utils.Ptr("test-user-data"),
+ Volumes: utils.Ptr([]string{testVolumeId}),
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateServerRequest)) iaas.ApiCreateServerRequest {
+ request := testClient.CreateServer(testCtx, testProjectId, testRegion)
+ request = request.CreateServerPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateServerRequest)) iaas.ApiCreateServerRequest {
+ request := testClient.CreateServer(testCtx, testProjectId, testRegion)
+ request = request.CreateServerPayload(iaas.CreateServerPayload{
+ MachineType: utils.Ptr("t1.1"),
+ Name: utils.Ptr("test-server-name"),
+ })
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateServerPayload)) iaas.CreateServerPayload {
+ payload := iaas.CreateServerPayload{
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ MachineType: utils.Ptr("t1.1"),
+ Name: utils.Ptr("test-server-name"),
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ AffinityGroup: utils.Ptr("test-affinity-group"),
+ 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.ServerBootVolume{
+ PerformanceClass: utils.Ptr("test-perf-class"),
+ Size: utils.Ptr(int64(5)),
+ DeleteOnTermination: utils.Ptr(false),
+ Source: &iaas.BootVolumeSource{
+ Id: utils.Ptr(testSourceId),
+ Type: utils.Ptr("test-source-type"),
+ },
+ },
+ Networking: &iaas.CreateServerPayloadAllOfNetworking{
+ CreateServerNetworking: &iaas.CreateServerNetworking{
+ NetworkId: utils.Ptr(testNetworkId),
+ },
+ },
+ }
+ 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, affinityGroupFlag)
+ delete(flagValues, availabilityZoneFlag)
+ delete(flagValues, labelFlag)
+ delete(flagValues, bootVolumeSourceIdFlag)
+ delete(flagValues, bootVolumeSourceTypeFlag)
+ delete(flagValues, bootVolumeSizeFlag)
+ delete(flagValues, bootVolumePerformanceClassFlag)
+ delete(flagValues, bootVolumeDeleteOnTerminationFlag)
+ delete(flagValues, keypairNameFlag)
+ 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) {
+ model.AffinityGroup = nil
+ model.AvailabilityZone = nil
+ model.Labels = nil
+ model.BootVolumeSourceId = nil
+ model.BootVolumeSourceType = nil
+ model.BootVolumeSize = nil
+ model.BootVolumePerformanceClass = nil
+ model.BootVolumeDeleteOnTermination = nil
+ model.KeypairName = nil
+ model.NetworkInterfaceIds = nil
+ model.SecurityGroups = nil
+ model.ServiceAccountMails = nil
+ model.UserData = nil
+ model.Volumes = nil
+ model.ImageId = utils.Ptr(testImageId)
+ }),
+ },
+ {
+ description: "machine type missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, machineTypeFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "name missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, nameFlag)
+ }),
+ isValid: false,
+ },
+ {
+ 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: "use network id",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = testNetworkId
+ flagValues[nameFlag] = "test-server-name"
+ flagValues[machineTypeFlag] = "t1.1"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.NetworkId = utils.Ptr(testNetworkId)
+ model.Name = utils.Ptr("test-server-name")
+ model.MachineType = utils.Ptr("t1.1")
+ }),
+ },
+ {
+ description: "use boot volume source id and type",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[bootVolumeSourceIdFlag] = testImageId
+ flagValues[bootVolumeSourceTypeFlag] = "image"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.BootVolumeSourceId = utils.Ptr(testImageId)
+ model.BootVolumeSourceType = utils.Ptr("image")
+ }),
+ },
+ {
+ description: "invalid without image-id, boot-volume-source-id and type",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, bootVolumeSourceIdFlag)
+ delete(flagValues, bootVolumeSourceTypeFlag)
+ delete(flagValues, imageIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "invalid with boot-volume-source-id and without type",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, bootVolumeSourceTypeFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "invalid with boot-volume-source-type is image and without size",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, bootVolumeSizeFlag)
+ flagValues[bootVolumeSourceIdFlag] = testImageId
+ flagValues[bootVolumeSourceTypeFlag] = "image"
+ }),
+ isValid: false,
+ },
+ {
+ description: "valid with image-id",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ 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)
+ }),
+ },
+ {
+ description: "valid with boot-volume-source-id and type volume",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, imageIdFlag)
+ delete(flagValues, bootVolumeSizeFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.ImageId = nil
+ model.BootVolumeSize = nil
+ }),
+ },
+ {
+ description: "valid with boot-volume-source-id, type volume and size",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, imageIdFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.ImageId = 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.ApiCreateServerRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "only name and machine type in payload",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Region: testRegion,
+ Verbosity: globalflags.VerbosityDefault,
+ },
+ MachineType: utils.Ptr("t1.1"),
+ Name: utils.Ptr("test-server-name"),
+ },
+ expectedRequest: fixtureRequiredRequest(),
+ },
+ }
+
+ 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
+ server *iaas.Server
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty with iaas server",
+ args: args{
+ server: &iaas.Server{},
+ },
+ 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.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
new file mode 100644
index 000000000..bed42bf7a
--- /dev/null
+++ b/internal/cmd/server/deallocate/deallocate.go
@@ -0,0 +1,121 @@
+package deallocate
+
+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/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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("deallocate %s", serverIdArg),
+ Short: "Deallocates an existing server",
+ Long: "Deallocates an existing server.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Deallocate an existing server with ID "xxx"`,
+ "$ stackit server deallocate 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("server deallocate: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Deallocating server")
+ _, err = wait.DeallocateServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for server deallocating: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Deallocated"
+ if model.Async {
+ operationState = "Triggered deallocation of"
+ }
+ params.Printer.Info("%s server %q\n", operationState, serverLabel)
+
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ }
+
+ 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.Region, model.ServerId)
+}
diff --git a/internal/cmd/server/deallocate/deallocate_test.go b/internal/cmd/server/deallocate/deallocate_test.go
new file mode 100644
index 000000000..efb00e27f
--- /dev/null
+++ b/internal/cmd/server/deallocate/deallocate_test.go
@@ -0,0 +1,165 @@
+package deallocate
+
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ ProjectId: testProjectId,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeallocateServerRequest)) iaas.ApiDeallocateServerRequest {
+ request := testClient.DeallocateServer(testCtx, testProjectId, testRegion, testServerId)
+ 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: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "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.ApiDeallocateServerRequest
+ }{
+ {
+ 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/delete/delete.go b/internal/cmd/server/delete/delete.go
new file mode 100644
index 000000000..412c099dc
--- /dev/null
+++ b/internal/cmd/server/delete/delete.go
@@ -0,0 +1,123 @@
+package delete
+
+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/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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", serverIdArg),
+ Short: "Deletes a server",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Deletes a server.",
+ "If the server is still in use, the deletion will fail",
+ ),
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete server with ID "xxx"`,
+ "$ stackit server 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
+ }
+
+ 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.ProjectId
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete server: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting server")
+ _, err = wait.DeleteServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for server deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Deleted"
+ if model.Async {
+ operationState = "Triggered deletion of"
+ }
+ params.Printer.Info("%s server %q\n", operationState, serverLabel)
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ }
+
+ 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.Region, model.ServerId)
+}
diff --git a/internal/cmd/server/delete/delete_test.go b/internal/cmd/server/delete/delete_test.go
new file mode 100644
index 000000000..9534c8b22
--- /dev/null
+++ b/internal/cmd/server/delete/delete_test.go
@@ -0,0 +1,175 @@
+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")
+var testClient = &iaas.APIClient{}
+var testServerId = uuid.NewString()
+var testProjectId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteServerRequest)) iaas.ApiDeleteServerRequest {
+ request := testClient.DeleteServer(testCtx, testProjectId, testRegion, testServerId)
+ 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: "server id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server 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 iaas.ApiDeleteServerRequest
+ }{
+ {
+ 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/describe/describe.go b/internal/cmd/server/describe/describe.go
new file mode 100644
index 000000000..f13e6f5c8
--- /dev/null
+++ b/internal/cmd/server/describe/describe.go
@@ -0,0 +1,248 @@
+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"
+ "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/goccy/go-yaml"
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", serverIdArg),
+ Short: "Shows details of a server",
+ Long: "Shows details of a server.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a server with ID "xxx"`,
+ "$ stackit server describe xxx",
+ ),
+ examples.NewExample(
+ `Show details of a server with ID "xxx" in JSON format`,
+ "$ stackit server 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 server: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ }
+
+ 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.Region, model.ServerId)
+ req = req.Details(true)
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, server *iaas.Server) error {
+ if server == nil {
+ return fmt.Errorf("api response is empty")
+ }
+ 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:
+ // 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)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ content := []tables.Table{}
+
+ table := tables.NewTable()
+ table.SetTitle("Server")
+
+ table.AddRow("ID", utils.PtrString(server.Id))
+ table.AddSeparator()
+ table.AddRow("NAME", utils.PtrString(server.Name))
+ table.AddSeparator()
+ table.AddRow("STATE", utils.PtrString(server.Status))
+ table.AddSeparator()
+ table.AddRow("AVAILABILITY ZONE", utils.PtrString(server.AvailabilityZone))
+ table.AddSeparator()
+ if server.BootVolume != nil && server.BootVolume.Id != nil {
+ table.AddRow("BOOT VOLUME", *server.BootVolume.Id)
+ table.AddSeparator()
+ table.AddRow("DELETE ON TERMINATION", utils.PtrString(server.BootVolume.DeleteOnTermination))
+ table.AddSeparator()
+ }
+ table.AddRow("POWER STATUS", utils.PtrString(server.PowerStatus))
+ table.AddSeparator()
+
+ if server.AffinityGroup != nil {
+ table.AddRow("AFFINITY GROUP", *server.AffinityGroup)
+ table.AddSeparator()
+ }
+
+ if server.ImageId != nil {
+ table.AddRow("IMAGE", *server.ImageId)
+ table.AddSeparator()
+ }
+
+ if server.KeypairName != nil {
+ table.AddRow("KEYPAIR", *server.KeypairName)
+ table.AddSeparator()
+ }
+
+ if server.MachineType != nil {
+ table.AddRow("MACHINE TYPE", *server.MachineType)
+ table.AddSeparator()
+ }
+
+ if server.Labels != nil && len(*server.Labels) > 0 {
+ labels := []string{}
+ for key, value := range *server.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ table.AddSeparator()
+ }
+
+ if server.ServiceAccountMails != nil && len(*server.ServiceAccountMails) > 0 {
+ table.AddRow("SERVICE ACCOUNTS", strings.Join(*server.ServiceAccountMails, "\n"))
+ table.AddSeparator()
+ }
+
+ if server.Volumes != nil && len(*server.Volumes) > 0 {
+ volumes := []string{}
+ volumes = append(volumes, *server.Volumes...)
+ table.AddRow("VOLUMES", strings.Join(volumes, "\n"))
+ table.AddSeparator()
+ }
+
+ content = append(content, table)
+
+ if server.Nics != nil && len(*server.Nics) > 0 {
+ nicsTable := tables.NewTable()
+ nicsTable.SetTitle("Attached Network Interfaces")
+ nicsTable.SetHeader("ID", "NETWORK ID", "NETWORK NAME", "IPv4", "PUBLIC IP")
+
+ for _, nic := range *server.Nics {
+ nicsTable.AddRow(
+ utils.PtrString(nic.NicId),
+ utils.PtrString(nic.NetworkId),
+ utils.PtrString(nic.NetworkName),
+ utils.PtrString(nic.Ipv4),
+ utils.PtrString(nic.PublicIp),
+ )
+ nicsTable.AddSeparator()
+ }
+
+ content = append(content, nicsTable)
+ }
+
+ if server.MaintenanceWindow != nil {
+ maintenanceWindow := tables.NewTable()
+ maintenanceWindow.SetTitle("Maintenance Window")
+
+ if server.MaintenanceWindow.Status != nil {
+ maintenanceWindow.AddRow("STATUS", *server.MaintenanceWindow.Status)
+ maintenanceWindow.AddSeparator()
+ }
+ if server.MaintenanceWindow.Details != nil {
+ maintenanceWindow.AddRow("DETAILS", *server.MaintenanceWindow.Details)
+ maintenanceWindow.AddSeparator()
+ }
+ if server.MaintenanceWindow.StartsAt != nil {
+ maintenanceWindow.AddRow(
+ "STARTS AT",
+ utils.ConvertTimePToDateTimeString(server.MaintenanceWindow.StartsAt),
+ )
+ maintenanceWindow.AddSeparator()
+ }
+ if server.MaintenanceWindow.EndsAt != nil {
+ maintenanceWindow.AddRow(
+ "ENDS AT",
+ utils.ConvertTimePToDateTimeString(server.MaintenanceWindow.EndsAt),
+ )
+ maintenanceWindow.AddSeparator()
+ }
+
+ content = append(content, maintenanceWindow)
+ }
+
+ err := tables.DisplayTables(p, content)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ }
+}
diff --git a/internal/cmd/server/describe/describe_test.go b/internal/cmd/server/describe/describe_test.go
new file mode 100644
index 000000000..0b835a8da
--- /dev/null
+++ b/internal/cmd/server/describe/describe_test.go
@@ -0,0 +1,214 @@
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetServerRequest)) iaas.ApiGetServerRequest {
+ request := testClient.GetServer(testCtx, testProjectId, testRegion, testServerId)
+ request = request.Details(true)
+ 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: "server id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server 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 iaas.ApiGetServerRequest
+ }{
+ {
+ 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
+ server *iaas.Server
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty server",
+ args: args{
+ outputFormat: "",
+ server: &iaas.Server{},
+ },
+ 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.server); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/list/list.go b/internal/cmd/server/list/list.go
new file mode 100644
index 000000000..54c058dc0
--- /dev/null
+++ b/internal/cmd/server/list/list.go
@@ -0,0 +1,203 @@
+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"
+ "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"
+
+ "github.com/goccy/go-yaml"
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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 servers of a project",
+ Long: "Lists all servers of a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all servers`,
+ "$ stackit server list",
+ ),
+ examples.NewExample(
+ `Lists all servers which contains the label xxx`,
+ "$ stackit server list --label-selector xxx",
+ ),
+ examples.NewExample(
+ `Lists all servers in JSON format`,
+ "$ stackit server list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 servers`,
+ "$ stackit server 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("list servers: %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 servers found for project %q\n", projectLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListServersRequest {
+ req := apiClient.ListServers(ctx, model.ProjectId, model.Region)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+ req = req.Details(true)
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, servers []iaas.Server) error {
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(servers, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal server: %w", err)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ case print.YAMLOutputFormat:
+ // 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)
+ }
+ p.Outputln(string(details))
+
+ return nil
+ default:
+ table := tables.NewTable()
+ table.SetHeader("ID", "Name", "Status", "Machine Type", "Availability Zones", "Nic IPv4", "Public IPs")
+
+ for i := range servers {
+ server := servers[i]
+
+ nicIPv4 := ""
+ publicIPs := ""
+ if server.Nics != nil && len(*server.Nics) > 0 {
+ for i, nic := range *server.Nics {
+ if nic.Ipv4 != nil || nic.PublicIp != nil {
+ nicIPv4 += utils.PtrString(nic.Ipv4)
+ publicIPs += utils.PtrString(nic.PublicIp)
+
+ if i != len(*server.Nics)-1 {
+ publicIPs += "\n"
+ nicIPv4 += "\n"
+ }
+ }
+ }
+ }
+
+ table.AddRow(
+ utils.PtrString(server.Id),
+ utils.PtrString(server.Name),
+ utils.PtrString(server.Status),
+ utils.PtrString(server.MachineType),
+ utils.PtrString(server.AvailabilityZone),
+ nicIPv4,
+ publicIPs,
+ )
+ }
+
+ p.Outputln(table.Render())
+ return nil
+ }
+}
diff --git a/internal/cmd/server/list/list_test.go b/internal/cmd/server/list/list_test.go
new file mode 100644
index 000000000..4eb3a78cf
--- /dev/null
+++ b/internal/cmd/server/list/list_test.go
@@ -0,0 +1,202 @@
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testLabelSelector = "label"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ limitFlag: "10",
+ labelSelectorFlag: testLabelSelector,
+ }
+ 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,
+ },
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListServersRequest)) iaas.ApiListServersRequest {
+ request := testClient.ListServers(testCtx, testProjectId, testRegion)
+ request = request.LabelSelector(testLabelSelector)
+ request = request.Details(true)
+ 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 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: "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: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ 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.ApiListServersRequest
+ }{
+ {
+ 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
+ servers []iaas.Server
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.servers); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/log/log.go b/internal/cmd/server/log/log.go
new file mode 100644
index 000000000..a211a44c1
--- /dev/null
+++ b/internal/cmd/server/log/log.go
@@ -0,0 +1,140 @@
+package log
+
+import (
+ "context"
+ "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"
+ "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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+
+ lengthLimitFlag = "length"
+ defaultLengthLimit = 2000 // lines
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ Length *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("log %s", serverIdArg),
+ Short: "Gets server console log",
+ Long: "Gets server console log.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get server console log for the server with ID "xxx"`,
+ "$ stackit server log xxx",
+ ),
+ examples.NewExample(
+ `Get server console log for the server with ID "xxx" and limit output lines to 1000`,
+ "$ stackit server log xxx --length 1000",
+ ),
+ examples.NewExample(
+ `Get server console log for the server with ID "xxx" in JSON format`,
+ "$ stackit server log 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
+ }
+
+ 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
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("server log: %w", err)
+ }
+
+ log := resp.GetOutput()
+ lines := strings.Split(log, "\n")
+
+ if len(lines) > int(*model.Length) {
+ // Truncate output and show most recent logs
+ start := len(lines) - int(*model.Length)
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, strings.Join(lines[start:], "\n"))
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, log)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(lengthLimitFlag, defaultLengthLimit, "Maximum number of lines to list")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ length := flags.FlagWithDefaultToInt64Value(p, cmd, lengthLimitFlag)
+ if length < 0 {
+ return nil, &errors.FlagValidationError{
+ Flag: lengthLimitFlag,
+ Details: "must not be negative",
+ }
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ Length: utils.Ptr(length),
+ }
+
+ 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.Region, model.ServerId)
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel, log string) error {
+ 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
new file mode 100644
index 000000000..87375530f
--- /dev/null
+++ b/internal/cmd/server/log/log_test.go
@@ -0,0 +1,211 @@
+package log
+
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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,
+
+ lengthLimitFlag: "3000",
+ }
+ 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,
+ Length: utils.Ptr(int64(3000)),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetServerLogRequest)) iaas.ApiGetServerLogRequest {
+ request := testClient.GetServerLog(testCtx, testProjectId, testRegion, testServerId)
+ 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: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "invalid-uuid"
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "optional length missing (test default value)",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, lengthLimitFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Length = utils.Ptr(int64(2000))
+ }),
+ },
+ }
+
+ 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.ApiGetServerLogRequest
+ }{
+ {
+ 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
+ serverLabel string
+ log string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serverLabel, tt.args.log); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/machine-type/describe/describe.go b/internal/cmd/server/machine-type/describe/describe.go
new file mode 100644
index 000000000..d496d5a83
--- /dev/null
+++ b/internal/cmd/server/machine-type/describe/describe.go
@@ -0,0 +1,117 @@
+package describe
+
+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/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/spf13/cobra"
+)
+
+const (
+ machineTypeArg = "MACHINE_TYPE"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ MachineType string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", machineTypeArg),
+ Short: "Shows details of a server machine type",
+ Long: "Shows details of a server machine type.",
+ Args: args.SingleArg(machineTypeArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a server machine type with name "xxx"`,
+ "$ stackit server machine-type describe xxx",
+ ),
+ examples.NewExample(
+ `Show details of a server machine type with name "xxx" in JSON format`,
+ "$ stackit server machine-type 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 server machine type: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ machineType := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ MachineType: machineType,
+ }
+
+ 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.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")
+ }
+ return p.OutputResult(outputFormat, machineType, func() error {
+ table := tables.NewTable()
+ table.AddRow("NAME", utils.PtrString(machineType.Name))
+ table.AddSeparator()
+ table.AddRow("VCPUS", utils.PtrString(machineType.Vcpus))
+ table.AddSeparator()
+ table.AddRow("RAM (in MB)", utils.PtrString(machineType.Ram))
+ table.AddSeparator()
+ table.AddRow("DISK SIZE (in GB)", utils.PtrString(machineType.Disk))
+ table.AddSeparator()
+ table.AddRow("DESCRIPTION", utils.PtrString(machineType.Description))
+ table.AddSeparator()
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..4343f6fb6
--- /dev/null
+++ b/internal/cmd/server/machine-type/describe/describe_test.go
@@ -0,0 +1,193 @@
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testMachineType = "t1.1"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testMachineType,
+ }
+ 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,
+ },
+ MachineType: testMachineType,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetMachineTypeRequest)) iaas.ApiGetMachineTypeRequest {
+ request := testClient.GetMachineType(testCtx, testProjectId, testRegion, testMachineType)
+ 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: "missing machine type arg",
+ 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,
+ },
+ }
+
+ 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.ApiGetMachineTypeRequest
+ }{
+ {
+ 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
+ machineType *iaas.MachineType
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.machineType); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/machine-type/list/list.go b/internal/cmd/server/machine-type/list/list.go
new file mode 100644
index 000000000..9dc3aad50
--- /dev/null
+++ b/internal/cmd/server/machine-type/list/list.go
@@ -0,0 +1,146 @@
+package list
+
+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"
+ "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"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+}
+
+const (
+ limitFlag = "limit"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Get list of all machine types available in a project",
+ Long: "Get list of all machine types available in a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Get list of all machine types`,
+ "$ stackit server machine-type list",
+ ),
+ examples.NewExample(
+ `Get list of all machine types in JSON format`,
+ "$ stackit server machine-type list --output-format json",
+ ),
+ examples.NewExample(
+ `List the first 10 machine types`,
+ `$ stackit server machine-type 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("read machine-types: %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 machine-types found for project %q\n", projectLabel)
+ return nil
+ }
+
+ // limit output
+ if model.Limit != nil && len(*resp.Items) > int(*model.Limit) {
+ *resp.Items = (*resp.Items)[:*model.Limit]
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+
+ 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: flags.FlagToInt64Pointer(p, cmd, limitFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListMachineTypesRequest {
+ return apiClient.ListMachineTypes(ctx, model.ProjectId, model.Region)
+}
+
+func outputResult(p *print.Printer, outputFormat string, machineTypes iaas.MachineTypeListResponse) error {
+ return p.OutputResult(outputFormat, machineTypes, func() error {
+ table := tables.NewTable()
+ table.SetTitle("Machine-Types")
+
+ table.SetHeader("NAME", "DESCRIPTION")
+ if items := machineTypes.GetItems(); len(items) > 0 {
+ for _, machineType := range items {
+ table.AddRow(*machineType.Name, utils.PtrString(machineType.Description))
+ }
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/server/machine-type/list/list_test.go b/internal/cmd/server/machine-type/list/list_test.go
new file mode 100644
index 000000000..8b07ca64c
--- /dev/null
+++ b/internal/cmd/server/machine-type/list/list_test.go
@@ -0,0 +1,187 @@
+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")
+var testClient = &iaas.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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ Limit: utils.Ptr(int64(10)),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListMachineTypesRequest)) iaas.ApiListMachineTypesRequest {
+ request := testClient.ListMachineTypes(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: "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: "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.ApiListMachineTypesRequest
+ }{
+ {
+ 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
+ machineTypes iaas.MachineTypeListResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.machineTypes); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/machine-type/machine-type.go b/internal/cmd/server/machine-type/machine-type.go
new file mode 100644
index 000000000..ee4e2ae54
--- /dev/null
+++ b/internal/cmd/server/machine-type/machine-type.go
@@ -0,0 +1,28 @@
+package machinetype
+
+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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "machine-type",
+ Short: "Provides functionality for server machine types available inside a project",
+ Long: "Provides functionality for server machine types available inside a project.",
+ 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))
+}
diff --git a/internal/cmd/server/network-interface/attach/attach.go b/internal/cmd/server/network-interface/attach/attach.go
new file mode 100644
index 000000000..5bcf7ed6d
--- /dev/null
+++ b/internal/cmd/server/network-interface/attach/attach.go
@@ -0,0 +1,163 @@
+package attach
+
+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/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 (
+ serverIdFlag = "server-id"
+ networkInterfaceIdFlag = "network-interface-id"
+ createFlag = "create"
+ networkIdFlag = "network-id"
+
+ defaultCreateFlag = false
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId *string
+ NicId *string
+ NetworkId *string
+ Create *bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "attach",
+ Short: "Attaches a network interface to a server",
+ Long: "Attaches a network interface to a server.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Attach a network interface with ID "xxx" to a server with ID "yyy"`,
+ `$ stackit server network-interface attach --network-interface-id xxx --server-id yyy`,
+ ),
+ examples.NewExample(
+ `Create a network interface for network with ID "xxx" and attach it to a server with ID "yyy"`,
+ `$ stackit server network-interface attach --network-id xxx --server-id yyy --create`,
+ ),
+ ),
+ 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
+ }
+
+ // 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.Region, *model.NetworkId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get network name: %v", err)
+ networkLabel = *model.NetworkId
+ }
+ 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)
+ }
+ params.Printer.Info("Created a network interface for network %q and attached it to server %q\n", networkLabel, serverLabel)
+ return nil
+ }
+
+ 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)
+ }
+ params.Printer.Info("Attached network interface %q to server %q\n", utils.PtrString(model.NicId), 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(), networkInterfaceIdFlag, "Network Interface ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID")
+ cmd.Flags().BoolP(createFlag, "b", defaultCreateFlag, "If this is set a network interface will be created. (default false)")
+
+ cmd.MarkFlagsRequiredTogether(createFlag, networkIdFlag)
+ cmd.MarkFlagsMutuallyExclusive(createFlag, networkInterfaceIdFlag)
+ cmd.MarkFlagsMutuallyExclusive(networkIdFlag, networkInterfaceIdFlag)
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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{}
+ }
+
+ // if create is not provided then network-interface-id is needed
+ networkInterfaceId := flags.FlagToStringPointer(p, cmd, networkInterfaceIdFlag)
+ create := flags.FlagToBoolPointer(p, cmd, createFlag)
+ if create == nil && networkInterfaceId == nil {
+ return nil, &cliErr.ServerNicAttachMissingNicIdError{Cmd: cmd}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag),
+ NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag),
+ NicId: networkInterfaceId,
+ Create: create,
+ }
+
+ 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.Region, *model.ServerId, *model.NicId)
+}
+
+func buildRequestCreateAndAttach(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiAddNetworkToServerRequest {
+ 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
new file mode 100644
index 000000000..6e2898b32
--- /dev/null
+++ b/internal/cmd/server/network-interface/attach/attach_test.go
@@ -0,0 +1,270 @@
+package attach
+
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testNicId = uuid.NewString()
+var testNetworkId = uuid.NewString()
+
+// contains nic id
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ serverIdFlag: testServerId,
+ networkInterfaceIdFlag: testNicId,
+ }
+ 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: utils.Ptr(testServerId),
+ NicId: utils.Ptr(testNicId),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequestAttach(mods ...func(request *iaas.ApiAddNicToServerRequest)) iaas.ApiAddNicToServerRequest {
+ request := testClient.AddNicToServer(testCtx, testProjectId, testRegion, testServerId, testNicId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixtureRequestCreateAndAttach(mods ...func(request *iaas.ApiAddNetworkToServerRequest)) iaas.ApiAddNetworkToServerRequest {
+ request := testClient.AddNetworkToServer(testCtx, testProjectId, testRegion, testServerId, testNetworkId)
+ 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: "server id 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,
+ },
+ // only create
+ {
+ description: "provided flags invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[createFlag] = "true"
+ delete(flagValues, networkInterfaceIdFlag)
+ }),
+ isValid: false,
+ },
+ // only network id
+ {
+ description: "provided flags invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkInterfaceIdFlag)
+ flagValues[networkIdFlag] = testNetworkId
+ }),
+ isValid: false,
+ },
+ // create and nic id
+ {
+ description: "provided flags invalid 3",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[createFlag] = "true"
+ }),
+ isValid: false,
+ },
+ // create and network id (valid)
+ {
+ description: "provided flags valid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[createFlag] = "true"
+ delete(flagValues, networkInterfaceIdFlag)
+ flagValues[networkIdFlag] = testNetworkId
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Create = utils.Ptr(true)
+ model.NicId = nil
+ model.NetworkId = utils.Ptr(testNetworkId)
+ }),
+ },
+ // create, nic id and network id
+ {
+ description: "provided flags invalid 4",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[createFlag] = "true"
+ flagValues[networkIdFlag] = testNetworkId
+ }),
+ isValid: false,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Create = utils.Ptr(true)
+ model.NetworkId = utils.Ptr(testNetworkId)
+ }),
+ },
+ // network id and nic id
+ {
+ description: "provided flags invalid 5",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = testNetworkId
+ }),
+ isValid: false,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.NetworkId = utils.Ptr(testNetworkId)
+ }),
+ },
+ }
+
+ 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 TestBuildRequestAttach(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiAddNicToServerRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequestAttach(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequestAttach(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 TestBuildRequestCreateAndAttach(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiAddNetworkToServerRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.NicId = nil
+ model.NetworkId = utils.Ptr(testNetworkId)
+ model.Create = utils.Ptr(true)
+ }),
+ expectedRequest: fixtureRequestCreateAndAttach(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequestCreateAndAttach(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/network-interface/detach/detach.go b/internal/cmd/server/network-interface/detach/detach.go
new file mode 100644
index 000000000..ed891e912
--- /dev/null
+++ b/internal/cmd/server/network-interface/detach/detach.go
@@ -0,0 +1,165 @@
+package detach
+
+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/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 (
+ serverIdFlag = "server-id"
+ networkInterfaceIdFlag = "network-interface-id"
+ networkIdFlag = "network-id"
+ deleteFlag = "delete"
+
+ defaultDeleteFlag = false
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId *string
+ NicId *string
+ NetworkId *string
+ Delete *bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "detach",
+ Short: "Detaches a network interface from a server",
+ Long: "Detaches a network interface from a server.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Detach a network interface with ID "xxx" from a server with ID "yyy"`,
+ `$ stackit server network-interface detach --network-interface-id xxx --server-id yyy`,
+ ),
+ examples.NewExample(
+ `Detach and delete all network interfaces for network with ID "xxx" and detach them from a server with ID "yyy"`,
+ `$ stackit server network-interface detach --network-id xxx --server-id yyy --delete`,
+ ),
+ ),
+ 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
+ }
+
+ // 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.Region, *model.NetworkId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get network name: %v", err)
+ networkLabel = *model.NetworkId
+ }
+ 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)
+ }
+ params.Printer.Info("Detached and deleted all network interfaces of network %q from server %q\n", networkLabel, serverLabel)
+ return nil
+ }
+
+ 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)
+ }
+ params.Printer.Info("Detached network interface %q from server %q\n", utils.PtrString(model.NicId), 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(), networkInterfaceIdFlag, "Network Interface ID")
+ cmd.Flags().Var(flags.UUIDFlag(), networkIdFlag, "Network ID")
+ cmd.Flags().BoolP(deleteFlag, "b", defaultDeleteFlag, "If this is set all network interfaces will be deleted. (default false)")
+
+ cmd.MarkFlagsRequiredTogether(deleteFlag, networkIdFlag)
+ cmd.MarkFlagsMutuallyExclusive(deleteFlag, networkInterfaceIdFlag)
+ cmd.MarkFlagsMutuallyExclusive(networkIdFlag, networkInterfaceIdFlag)
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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{}
+ }
+
+ // if delete is not provided then network-interface-id is needed
+ networkInterfaceId := flags.FlagToStringPointer(p, cmd, networkInterfaceIdFlag)
+ deleteValue := flags.FlagToBoolPointer(p, cmd, deleteFlag)
+ if deleteValue == nil && networkInterfaceId == nil {
+ return nil, &cliErr.ServerNicDetachMissingNicIdError{Cmd: cmd}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringPointer(p, cmd, serverIdFlag),
+ NetworkId: flags.FlagToStringPointer(p, cmd, networkIdFlag),
+ NicId: networkInterfaceId,
+ Delete: deleteValue,
+ }
+
+ 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.Region, *model.ServerId, *model.NicId)
+}
+
+func buildRequestDetachAndDelete(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRemoveNetworkFromServerRequest {
+ 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
new file mode 100644
index 000000000..4946e3e41
--- /dev/null
+++ b/internal/cmd/server/network-interface/detach/detach_test.go
@@ -0,0 +1,270 @@
+package detach
+
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testNicId = uuid.NewString()
+var testNetworkId = uuid.NewString()
+
+// contains nic id
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ serverIdFlag: testServerId,
+ networkInterfaceIdFlag: testNicId,
+ }
+ 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: utils.Ptr(testServerId),
+ NicId: utils.Ptr(testNicId),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequestDetach(mods ...func(request *iaas.ApiRemoveNicFromServerRequest)) iaas.ApiRemoveNicFromServerRequest {
+ request := testClient.RemoveNicFromServer(testCtx, testProjectId, testRegion, testServerId, testNicId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixtureRequestDetachAndDelete(mods ...func(request *iaas.ApiRemoveNetworkFromServerRequest)) iaas.ApiRemoveNetworkFromServerRequest {
+ request := testClient.RemoveNetworkFromServer(testCtx, testProjectId, testRegion, testServerId, testNetworkId)
+ 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: "server id 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,
+ },
+ // only delete
+ {
+ description: "provided flags invalid 1",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[deleteFlag] = "true"
+ delete(flagValues, networkInterfaceIdFlag)
+ }),
+ isValid: false,
+ },
+ // only network id
+ {
+ description: "provided flags invalid 2",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, networkInterfaceIdFlag)
+ flagValues[networkIdFlag] = testNetworkId
+ }),
+ isValid: false,
+ },
+ // delete and nic id
+ {
+ description: "provided flags invalid 3",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[deleteFlag] = "true"
+ }),
+ isValid: false,
+ },
+ // delete and network id (valid)
+ {
+ description: "provided flags valid",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[deleteFlag] = "true"
+ delete(flagValues, networkInterfaceIdFlag)
+ flagValues[networkIdFlag] = testNetworkId
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Delete = utils.Ptr(true)
+ model.NicId = nil
+ model.NetworkId = utils.Ptr(testNetworkId)
+ }),
+ },
+ // delete, nic id and network id
+ {
+ description: "provided flags invalid 4",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[deleteFlag] = "true"
+ flagValues[networkIdFlag] = testNetworkId
+ }),
+ isValid: false,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Delete = utils.Ptr(true)
+ model.NetworkId = utils.Ptr(testNetworkId)
+ }),
+ },
+ // network id and nic id
+ {
+ description: "provided flags invalid 5",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[networkIdFlag] = testNetworkId
+ }),
+ isValid: false,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.NetworkId = utils.Ptr(testNetworkId)
+ }),
+ },
+ }
+
+ 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 TestBuildRequestDetach(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiRemoveNicFromServerRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequestDetach(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequestDetach(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 TestBuildRequestDetachAndDelete(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiRemoveNetworkFromServerRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.NicId = nil
+ model.NetworkId = utils.Ptr(testNetworkId)
+ model.Delete = utils.Ptr(true)
+ }),
+ expectedRequest: fixtureRequestDetachAndDelete(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequestDetachAndDelete(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/network-interface/list/list.go b/internal/cmd/server/network-interface/list/list.go
new file mode 100644
index 000000000..607798a89
--- /dev/null
+++ b/internal/cmd/server/network-interface/list/list.go
@@ -0,0 +1,149 @@
+package list
+
+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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ serverIdFlag = "server-id"
+ limitFlag = "limit"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ Limit *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all attached network interfaces of a server",
+ Long: "Lists all attached network interfaces of a server.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all attached network interfaces of server with ID "xxx"`,
+ "$ stackit server network-interface list --server-id xxx",
+ ),
+ examples.NewExample(
+ `Lists all attached network interfaces of server with ID "xxx" in JSON format`,
+ "$ stackit server network-interface list --server-id xxx --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 attached network interfaces of server with ID "xxx"`,
+ "$ stackit server network-interface list --server-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 attached network interfaces: %w", err)
+ }
+
+ if resp.Items == nil || len(*resp.Items) == 0 {
+ 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
+ }
+ params.Printer.Info("No attached network interfaces found for server %q\n", serverLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ if model.Limit != nil && len(items) > int(*model.Limit) {
+ items = items[:*model.Limit]
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.ServerId, items)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID")
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ Limit: limit,
+ }
+
+ 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.Region, model.ServerId)
+}
+
+func outputResult(p *print.Printer, outputFormat, serverId string, serverNics []iaas.NIC) error {
+ return p.OutputResult(outputFormat, serverNics, func() error {
+ table := tables.NewTable()
+ table.SetHeader("NIC ID", "SERVER ID")
+
+ for i := range serverNics {
+ nic := serverNics[i]
+ table.AddRow(utils.PtrString(nic.Id), serverId)
+ }
+ table.EnableAutoMergeOnColumns(2)
+
+ 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
new file mode 100644
index 000000000..27f411166
--- /dev/null
+++ b/internal/cmd/server/network-interface/list/list_test.go
@@ -0,0 +1,216 @@
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = 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",
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ Limit: utils.Ptr(int64(10)),
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListServerNICsRequest)) iaas.ApiListServerNICsRequest {
+ request := testClient.ListServerNICs(testCtx, testProjectId, testRegion, testServerId)
+ 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: "server id 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,
+ },
+ {
+ 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.ApiListServerNICsRequest
+ }{
+ {
+ 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
+ serverId string
+ serverNics []iaas.NIC
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty nic",
+ args: args{
+ serverNics: []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 := outputResult(p, tt.args.outputFormat, tt.args.serverId, tt.args.serverNics); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/network-interface/network-interface.go b/internal/cmd/server/network-interface/network-interface.go
new file mode 100644
index 000000000..2496def12
--- /dev/null
+++ b/internal/cmd/server/network-interface/network-interface.go
@@ -0,0 +1,30 @@
+package networkinterface
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/network-interface/attach"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "network-interface",
+ Short: "Allows attaching/detaching network interfaces to servers",
+ Long: "Allows attaching/detaching network interfaces 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(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
new file mode 100644
index 000000000..1d1ad8962
--- /dev/null
+++ b/internal/cmd/server/os-update/create/create.go
@@ -0,0 +1,135 @@
+package create
+
+import (
+ "context"
+ "fmt"
+
+ "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"
+ 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"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverupdate"
+)
+
+const (
+ serverIdFlag = "server-id"
+ maintenanceWindowFlag = "maintenance-window"
+ defaultMaintenanceWindow = 23
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ ServerId string
+ MaintenanceWindow int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a Server os-update.",
+ Long: "Creates a Server os-update. Operation always is async.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a Server os-update with name "myupdate"`,
+ `$ stackit server os-update create --server-id xxx`),
+ examples.NewExample(
+ `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, 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 := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create Server os-update: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().Int64P(maintenanceWindowFlag, "m", defaultMaintenanceWindow, "Maintenance window (in hours, 1-24)")
+}
+
+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),
+ MaintenanceWindow: flags.FlagWithDefaultToInt64Value(p, cmd, maintenanceWindowFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) (serverupdate.ApiCreateUpdateRequest, error) {
+ req := apiClient.CreateUpdate(ctx, model.ProjectId, model.ServerId, model.Region)
+ payload := serverupdate.CreateUpdatePayload{
+ MaintenanceWindow: &model.MaintenanceWindow,
+ }
+ req = req.CreateUpdatePayload(payload)
+ return req, nil
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel string, resp serverupdate.Update) error {
+ 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
new file mode 100644
index 000000000..c95f62de3
--- /dev/null
+++ b/internal/cmd/server/os-update/create/create_test.go
@@ -0,0 +1,202 @@
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testServerId = 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,
+ maintenanceWindowFlag: "13",
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ MaintenanceWindow: int64(13),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiCreateUpdateRequest)) serverupdate.ApiCreateUpdateRequest {
+ request := testClient.CreateUpdate(testCtx, testProjectId, testServerId, testRegion)
+ request = request.CreateUpdatePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *serverupdate.CreateUpdatePayload)) serverupdate.CreateUpdatePayload {
+ payload := serverupdate.CreateUpdatePayload{
+ MaintenanceWindow: utils.Ptr(int64(13)),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "with defaults",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, maintenanceWindowFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.MaintenanceWindow = 23
+ }),
+ },
+ {
+ 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,
+ },
+ }
+
+ 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 serverupdate.ApiCreateUpdateRequest
+ isValid bool
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error building request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ serverLabel string
+ resp serverupdate.Update
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serverLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/os-update/describe/describe.go b/internal/cmd/server/os-update/describe/describe.go
new file mode 100644
index 000000000..026cec137
--- /dev/null
+++ b/internal/cmd/server/os-update/describe/describe.go
@@ -0,0 +1,128 @@
+package describe
+
+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/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 (
+ updateIdArg = "UPDATE_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ UpdateId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", updateIdArg),
+ Short: "Shows details of a Server os-update",
+ Long: "Shows details of a Server os-update.",
+ Args: args.SingleArg(updateIdArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of a Server os-update with id "my-os-update-id"`,
+ "$ stackit server os-update describe my-os-update-id"),
+ examples.NewExample(
+ `Get details of a Server os-update with id "my-os-update-id" in JSON format`,
+ "$ stackit server os-update describe my-os-update-id --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 server os-update: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ updateId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ UpdateId: updateId,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) serverupdate.ApiGetUpdateRequest {
+ req := apiClient.GetUpdate(ctx, model.ProjectId, model.ServerId, model.UpdateId, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, update serverupdate.Update) error {
+ return p.OutputResult(outputFormat, update, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(update.Id))
+ table.AddSeparator()
+ table.AddRow("STATUS", utils.PtrString(update.Status))
+ table.AddSeparator()
+ installedUpdates := utils.PtrStringDefault(update.InstalledUpdates, "n/a")
+ table.AddRow("INSTALLED UPDATES", installedUpdates)
+ table.AddSeparator()
+ failedUpdates := utils.PtrStringDefault(update.FailedUpdates, "n/a")
+ table.AddRow("FAILED UPDATES", failedUpdates)
+
+ table.AddRow("START DATE", utils.PtrString(update.StartDate))
+ table.AddSeparator()
+ table.AddRow("END DATE", utils.PtrString(update.EndDate))
+ table.AddSeparator()
+
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ return nil
+ })
+}
diff --git a/internal/cmd/server/os-update/describe/describe_test.go b/internal/cmd/server/os-update/describe/describe_test.go
new file mode 100644
index 000000000..646b5ac87
--- /dev/null
+++ b/internal/cmd/server/os-update/describe/describe_test.go
@@ -0,0 +1,198 @@
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testUpdateId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testUpdateId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ UpdateId: testUpdateId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiGetUpdateRequest)) serverupdate.ApiGetUpdateRequest {
+ request := testClient.GetUpdate(testCtx, testProjectId, testServerId, testUpdateId, 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",
+ 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,
+ },
+ }
+
+ 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
+ isValid bool
+ expectedRequest serverupdate.ApiGetUpdateRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ update serverupdate.Update
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.update); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/os-update/disable/disable.go b/internal/cmd/server/os-update/disable/disable.go
new file mode 100644
index 000000000..c2a308710
--- /dev/null
+++ b/internal/cmd/server/os-update/disable/disable.go
@@ -0,0 +1,113 @@
+package disable
+
+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"
+ 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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverupdate"
+)
+
+const (
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "disable",
+ Short: "Disables server os-update service",
+ Long: "Disables server os-update service.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Disable os-update functionality for your server.`,
+ "$ stackit server os-update disable --server-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
+ }
+
+ serverLabel := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("disable server os-update service: %w", err)
+ }
+
+ params.Printer.Info("Disabled Server os-update service for server %s\n", serverLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) serverupdate.ApiDisableServiceResourceRequest {
+ req := apiClient.DisableServiceResource(ctx, model.ProjectId, model.ServerId, model.Region)
+ return req
+}
diff --git a/internal/cmd/server/os-update/disable/disable_test.go b/internal/cmd/server/os-update/disable/disable_test.go
new file mode 100644
index 000000000..b7d28e23c
--- /dev/null
+++ b/internal/cmd/server/os-update/disable/disable_test.go
@@ -0,0 +1,144 @@
+package disable
+
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = 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,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiDisableServiceResourceRequest)) serverupdate.ApiDisableServiceResourceRequest {
+ request := testClient.DisableServiceResource(testCtx, testProjectId, testServerId, 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: "server id flag is missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ 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,
+ },
+ }
+
+ 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 serverupdate.ApiDisableServiceResourceRequest
+ }{
+ {
+ 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/os-update/enable/enable.go b/internal/cmd/server/os-update/enable/enable.go
new file mode 100644
index 000000000..0d4630c45
--- /dev/null
+++ b/internal/cmd/server/os-update/enable/enable.go
@@ -0,0 +1,117 @@
+package enable
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverupdate"
+)
+
+const (
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "enable",
+ Short: "Enables Server os-update service",
+ Long: "Enables Server os-update service.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Enable os-update functionality for your server`,
+ "$ stackit server os-update enable --server-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
+ }
+
+ serverLabel := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ if !strings.Contains(err.Error(), "Tried to activate already active service") {
+ return fmt.Errorf("enable server os-update: %w", err)
+ }
+ }
+
+ params.Printer.Info("Enabled os-update service for server %s\n", serverLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) serverupdate.ApiEnableServiceResourceRequest {
+ payload := serverupdate.EnableServiceResourcePayload{}
+ req := apiClient.EnableServiceResource(ctx, model.ProjectId, model.ServerId, model.Region).EnableServiceResourcePayload(payload)
+ return req
+}
diff --git a/internal/cmd/server/os-update/enable/enable_test.go b/internal/cmd/server/os-update/enable/enable_test.go
new file mode 100644
index 000000000..4ebed0b3a
--- /dev/null
+++ b/internal/cmd/server/os-update/enable/enable_test.go
@@ -0,0 +1,144 @@
+package enable
+
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = 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,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiEnableServiceResourceRequest)) serverupdate.ApiEnableServiceResourceRequest {
+ request := testClient.EnableServiceResource(testCtx, testProjectId, testServerId, testRegion).EnableServiceResourcePayload(serverupdate.EnableServiceResourcePayload{})
+ 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: "server id flag is missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ 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,
+ },
+ }
+
+ 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 serverupdate.ApiEnableServiceResourceRequest
+ }{
+ {
+ 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/os-update/list/list.go b/internal/cmd/server/os-update/list/list.go
new file mode 100644
index 000000000..7ff61504e
--- /dev/null
+++ b/internal/cmd/server/os-update/list/list.go
@@ -0,0 +1,168 @@
+package list
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverupdate"
+)
+
+const (
+ limitFlag = "limit"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ Limit *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all server os-updates",
+ Long: "Lists all server os-updates.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all os-updates for a server with ID "xxx"`,
+ "$ stackit server os-update list --server-id xxx"),
+ examples.NewExample(
+ `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, 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 server os-update: %w", err)
+ }
+ updates := *resp.Items
+ if len(updates) == 0 {
+ serverLabel := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+ params.Printer.Info("No os-updates found for server %s\n", serverLabel)
+ return nil
+ }
+
+ // Truncate output
+ if model.Limit != nil && len(updates) > int(*model.Limit) {
+ updates = updates[:*model.Limit]
+ }
+ return outputResult(params.Printer, model.OutputFormat, updates)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ Limit: limit,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) serverupdate.ApiListUpdatesRequest {
+ req := apiClient.ListUpdates(ctx, model.ProjectId, model.ServerId, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, updates []serverupdate.Update) error {
+ 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 {
+ s := updates[i]
+
+ endDate := utils.PtrStringDefault(s.EndDate, "n/a")
+
+ installed := "n/a"
+ if s.InstalledUpdates != nil {
+ installed = strconv.FormatInt(*s.InstalledUpdates, 10)
+ }
+
+ failed := "n/a"
+ if s.FailedUpdates != nil {
+ failed = strconv.FormatInt(*s.FailedUpdates, 10)
+ }
+
+ table.AddRow(
+ utils.PtrString(s.Id),
+ utils.PtrString(s.Status),
+ installed,
+ failed,
+ utils.PtrString(s.StartDate),
+ endDate,
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..99cf70484
--- /dev/null
+++ b/internal/cmd/server/os-update/list/list_test.go
@@ -0,0 +1,184 @@
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = 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",
+ serverIdFlag: testServerId,
+ }
+ 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)),
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiListUpdatesRequest)) serverupdate.ApiListUpdatesRequest {
+ request := testClient.ListUpdates(testCtx, testProjectId, testServerId, 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 serverupdate.ApiListUpdatesRequest
+ }{
+ {
+ 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
+ updates []serverupdate.Update
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.updates); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/os-update/os-update.go b/internal/cmd/server/os-update/os-update.go
new file mode 100644
index 000000000..53abb7893
--- /dev/null
+++ b/internal/cmd/server/os-update/os-update.go
@@ -0,0 +1,36 @@
+package osupdate
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/disable"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/enable"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "os-update",
+ Short: "Provides functionality for managed server updates",
+ Long: "Provides functionality for managed server updates.",
+ 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(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
new file mode 100644
index 000000000..421a173c6
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/create/create.go
@@ -0,0 +1,154 @@
+package create
+
+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"
+ "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"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverupdate"
+)
+
+const (
+ nameFlag = "name"
+ enabledFlag = "enabled"
+ rruleFlag = "rrule"
+ maintenanceWindowFlag = "maintenance-window"
+ serverIdFlag = "server-id"
+
+ defaultRrule = "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"
+ defaultMaintenanceWindow = 23
+ defaultEnabled = true
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ ServerId string
+ ScheduleName string
+ Enabled bool
+ Rrule string
+ MaintenanceWindow int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a Server os-update Schedule",
+ Long: "Creates a Server os-update Schedule.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a Server os-update Schedule with name "myschedule"`,
+ `$ stackit server os-update schedule create --server-id xxx --name=myschedule`),
+ examples.NewExample(
+ `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, 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 := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+
+ 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
+ req, err := buildRequest(ctx, model, apiClient)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create Server os-update Schedule: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().StringP(nameFlag, "n", "", "os-update schedule name")
+ cmd.Flags().Int64P(maintenanceWindowFlag, "d", defaultMaintenanceWindow, "os-update maintenance window (in hours, 1-24)")
+ cmd.Flags().BoolP(enabledFlag, "e", defaultEnabled, "Is the server os-update schedule enabled")
+ cmd.Flags().StringP(rruleFlag, "r", defaultRrule, "os-update RRULE (recurrence rule)")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag, 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, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ MaintenanceWindow: flags.FlagWithDefaultToInt64Value(p, cmd, maintenanceWindowFlag),
+ ScheduleName: flags.FlagToStringValue(p, cmd, nameFlag),
+ Rrule: flags.FlagWithDefaultToStringValue(p, cmd, rruleFlag),
+ Enabled: flags.FlagToBoolValue(p, cmd, enabledFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) (serverupdate.ApiCreateUpdateScheduleRequest, error) {
+ req := apiClient.CreateUpdateSchedule(ctx, model.ProjectId, model.ServerId, model.Region)
+ req = req.CreateUpdateSchedulePayload(serverupdate.CreateUpdateSchedulePayload{
+ Enabled: &model.Enabled,
+ Name: &model.ScheduleName,
+ Rrule: &model.Rrule,
+ MaintenanceWindow: &model.MaintenanceWindow,
+ })
+ return req, nil
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel string, resp serverupdate.UpdateSchedule) error {
+ 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
new file mode 100644
index 000000000..1e29f4f30
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/create/create_test.go
@@ -0,0 +1,209 @@
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testServerId = 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,
+ nameFlag: "example-schedule-name",
+ enabledFlag: "true",
+ rruleFlag: defaultRrule,
+ maintenanceWindowFlag: "23",
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ ScheduleName: "example-schedule-name",
+ Enabled: defaultEnabled,
+ Rrule: defaultRrule,
+ MaintenanceWindow: int64(23),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiCreateUpdateScheduleRequest)) serverupdate.ApiCreateUpdateScheduleRequest {
+ request := testClient.CreateUpdateSchedule(testCtx, testProjectId, testServerId, testRegion)
+ request = request.CreateUpdateSchedulePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *serverupdate.CreateUpdateSchedulePayload)) serverupdate.CreateUpdateSchedulePayload {
+ payload := serverupdate.CreateUpdateSchedulePayload{
+ Name: utils.Ptr("example-schedule-name"),
+ Enabled: utils.Ptr(defaultEnabled),
+ Rrule: utils.Ptr("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"),
+ MaintenanceWindow: utils.Ptr(int64(23)),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ aclValues []string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "with defaults",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, maintenanceWindowFlag)
+ }),
+ 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,
+ },
+ }
+
+ 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 serverupdate.ApiCreateUpdateScheduleRequest
+ isValid bool
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error building request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ serverLabel string
+ resp serverupdate.UpdateSchedule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serverLabel, tt.args.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/os-update/schedule/delete/delete.go b/internal/cmd/server/os-update/schedule/delete/delete.go
new file mode 100644
index 000000000..b26084c9a
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/delete/delete.go
@@ -0,0 +1,105 @@
+package delete
+
+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/services/serverosupdate/client"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverupdate"
+)
+
+const (
+ scheduleIdArg = "SCHEDULE_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ScheduleId string
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", scheduleIdArg),
+ Short: "Deletes a Server os-update Schedule",
+ Long: "Deletes a Server os-update Schedule.",
+ Args: args.SingleArg(scheduleIdArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete a Server os-update Schedule with ID "xxx" for server "zzz"`,
+ "$ stackit server os-update schedule delete xxx --server-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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("delete Server os-update Schedule: %w", err)
+ }
+
+ params.Printer.Info("Deleted server os-update schedule %q\n", model.ScheduleId)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ scheduleId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ScheduleId: scheduleId,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) serverupdate.ApiDeleteUpdateScheduleRequest {
+ req := apiClient.DeleteUpdateSchedule(ctx, model.ProjectId, model.ServerId, model.ScheduleId, model.Region)
+ return req
+}
diff --git a/internal/cmd/server/os-update/schedule/delete/delete_test.go b/internal/cmd/server/os-update/schedule/delete/delete_test.go
new file mode 100644
index 000000000..99b0aaafb
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/delete/delete_test.go
@@ -0,0 +1,166 @@
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+ testUpdateScheduleId = "5"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testUpdateScheduleId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ ScheduleId: testUpdateScheduleId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiDeleteUpdateScheduleRequest)) serverupdate.ApiDeleteUpdateScheduleRequest {
+ request := testClient.DeleteUpdateSchedule(testCtx, testProjectId, testServerId, testUpdateScheduleId, 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",
+ 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,
+ },
+ }
+
+ 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 serverupdate.ApiDeleteUpdateScheduleRequest
+ }{
+ {
+ 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/os-update/schedule/describe/describe.go b/internal/cmd/server/os-update/schedule/describe/describe.go
new file mode 100644
index 000000000..4e68b04bd
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/describe/describe.go
@@ -0,0 +1,124 @@
+package describe
+
+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/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 (
+ scheduleIdArg = "SCHEDULE_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ ScheduleId string
+}
+
+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",
+ Long: "Shows details of a Server os-update Schedule.",
+ Args: args.SingleArg(scheduleIdArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of a Server os-update Schedule with id "my-schedule-id"`,
+ "$ stackit server os-update schedule describe my-schedule-id"),
+ examples.NewExample(
+ `Get details of a Server os-update Schedule with id "my-schedule-id" in JSON format`,
+ "$ stackit server os-update schedule describe my-schedule-id --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 server os-update schedule: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ scheduleId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ ScheduleId: scheduleId,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) serverupdate.ApiGetUpdateScheduleRequest {
+ req := apiClient.GetUpdateSchedule(ctx, model.ProjectId, model.ServerId, model.ScheduleId, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, schedule serverupdate.UpdateSchedule) error {
+ return p.OutputResult(outputFormat, schedule, func() error {
+ table := tables.NewTable()
+ table.AddRow("SCHEDULE ID", utils.PtrString(schedule.Id))
+ table.AddSeparator()
+ table.AddRow("SCHEDULE NAME", utils.PtrString(schedule.Name))
+ table.AddSeparator()
+ table.AddRow("ENABLED", utils.PtrString(schedule.Enabled))
+ table.AddSeparator()
+ table.AddRow("RRULE", utils.PtrString(schedule.Rrule))
+ table.AddSeparator()
+ table.AddRow("MAINTENANCE WINDOW", utils.PtrString(schedule.MaintenanceWindow))
+ table.AddSeparator()
+
+ err := table.Display(p)
+ if err != nil {
+ return fmt.Errorf("render table: %w", err)
+ }
+
+ 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
new file mode 100644
index 000000000..0904ac5b5
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/describe/describe_test.go
@@ -0,0 +1,198 @@
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+ testScheduleId = "5"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testScheduleId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ },
+ ServerId: testServerId,
+ ScheduleId: testScheduleId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiGetUpdateScheduleRequest)) serverupdate.ApiGetUpdateScheduleRequest {
+ request := testClient.GetUpdateSchedule(testCtx, testProjectId, testServerId, testScheduleId, 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",
+ 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,
+ },
+ }
+
+ 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
+ isValid bool
+ expectedRequest serverupdate.ApiGetUpdateScheduleRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ isValid: true,
+ expectedRequest: fixtureRequest(),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request := buildRequest(testCtx, tt.model, testClient)
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ schedule serverupdate.UpdateSchedule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.schedule); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/os-update/schedule/list/list.go b/internal/cmd/server/os-update/schedule/list/list.go
new file mode 100644
index 000000000..0e300e547
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/list/list.go
@@ -0,0 +1,153 @@
+package list
+
+import (
+ "context"
+ "fmt"
+
+ "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-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"
+ 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 (
+ limitFlag = "limit"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ Limit *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all server os-update schedules",
+ Long: "Lists all server os-update schedules.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all os-update schedules for a server with ID "xxx"`,
+ "$ stackit server os-update schedule list --server-id xxx"),
+ examples.NewExample(
+ `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, 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 server os-update schedules: %w", err)
+ }
+ schedules := *resp.Items
+ if len(schedules) == 0 {
+ serverLabel := model.ServerId
+ // Get server name
+ 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 {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ } else if serverName != "" {
+ serverLabel = serverName
+ }
+ }
+ params.Printer.Info("No os-update schedules found for server %s\n", serverLabel)
+ return nil
+ }
+
+ // Truncate output
+ if model.Limit != nil && len(schedules) > int(*model.Limit) {
+ schedules = schedules[:*model.Limit]
+ }
+ return outputResult(params.Printer, model.OutputFormat, schedules)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ Limit: limit,
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient) serverupdate.ApiListUpdateSchedulesRequest {
+ req := apiClient.ListUpdateSchedules(ctx, model.ProjectId, model.ServerId, model.Region)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, schedules []serverupdate.UpdateSchedule) error {
+ return p.OutputResult(outputFormat, schedules, func() error {
+ table := tables.NewTable()
+ table.SetHeader("SCHEDULE ID", "SCHEDULE NAME", "ENABLED", "RRULE", "MAINTENANCE WINDOW")
+ for i := range schedules {
+ s := schedules[i]
+ table.AddRow(
+ utils.PtrString(s.Id),
+ utils.PtrString(s.Name),
+ utils.PtrString(s.Enabled),
+ utils.PtrString(s.Rrule),
+ utils.PtrString(s.MaintenanceWindow),
+ )
+ }
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..5f4fceebc
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/list/list_test.go
@@ -0,0 +1,184 @@
+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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = 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",
+ serverIdFlag: testServerId,
+ }
+ 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)),
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiListUpdateSchedulesRequest)) serverupdate.ApiListUpdateSchedulesRequest {
+ request := testClient.ListUpdateSchedules(testCtx, testProjectId, testServerId, 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 serverupdate.ApiListUpdateSchedulesRequest
+ }{
+ {
+ 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
+ schedules []serverupdate.UpdateSchedule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.schedules); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/os-update/schedule/schedule.go b/internal/cmd/server/os-update/schedule/schedule.go
new file mode 100644
index 000000000..d3ccd6b63
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/schedule.go
@@ -0,0 +1,34 @@
+package schedule
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/schedule/create"
+ del "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/schedule/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update/schedule/describe"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "schedule",
+ Short: "Provides functionality for Server os-update Schedule",
+ Long: "Provides functionality for Server os-update Schedule.",
+ 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(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
new file mode 100644
index 000000000..72c3f92f4
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/update/update.go
@@ -0,0 +1,164 @@
+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"
+ 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/serverosupdate/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverupdate"
+)
+
+const (
+ scheduleIdArg = "SCHEDULE_ID"
+
+ nameFlag = "name"
+ enabledFlag = "enabled"
+ rruleFlag = "rrule"
+ maintenanceWindowFlag = "maintenance-window"
+ serverIdFlag = "server-id"
+
+ defaultRrule = "DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"
+ defaultMaintenanceWindow = 23
+ defaultEnabled = true
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+
+ ServerId string
+ ScheduleId string
+ ScheduleName *string
+ Enabled *bool
+ Rrule *string
+ MaintenanceWindow *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", scheduleIdArg),
+ Short: "Updates a Server os-update Schedule",
+ Long: "Updates a Server os-update Schedule.",
+ Example: examples.Build(
+ examples.NewExample(
+ `Update the name of the os-update schedule "zzz" of server "xxx"`,
+ "$ stackit server os-update schedule update zzz --server-id=xxx --name=newname"),
+ ),
+ Args: args.SingleArg(scheduleIdArg, nil),
+ 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
+ }
+
+ currentSchedule, err := apiClient.GetUpdateScheduleExecute(ctx, model.ProjectId, model.ServerId, model.ScheduleId, model.Region)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get current server os-update schedule: %v", err)
+ 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
+ req, err := buildRequest(ctx, model, apiClient, *currentSchedule)
+ if err != nil {
+ return err
+ }
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update Server os-update Schedule: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ cmd.Flags().StringP(nameFlag, "n", "", "os-update schedule name")
+ cmd.Flags().Int64P(maintenanceWindowFlag, "d", defaultMaintenanceWindow, "Maintenance window (in hours, 1-24)")
+ cmd.Flags().BoolP(enabledFlag, "e", defaultEnabled, "Is the server os-update schedule enabled")
+ cmd.Flags().StringP(rruleFlag, "r", defaultRrule, "os-update RRULE (recurrence rule)")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ scheduleId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ScheduleId: scheduleId,
+ ScheduleName: flags.FlagToStringPointer(p, cmd, nameFlag),
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ MaintenanceWindow: flags.FlagToInt64Pointer(p, cmd, maintenanceWindowFlag),
+ Rrule: flags.FlagToStringPointer(p, cmd, rruleFlag),
+ Enabled: flags.FlagToBoolPointer(p, cmd, enabledFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serverupdate.APIClient, old serverupdate.UpdateSchedule) (serverupdate.ApiUpdateUpdateScheduleRequest, error) {
+ req := apiClient.UpdateUpdateSchedule(ctx, model.ProjectId, model.ServerId, model.ScheduleId, model.Region)
+
+ if model.MaintenanceWindow != nil {
+ old.MaintenanceWindow = model.MaintenanceWindow
+ }
+ if model.Enabled != nil {
+ old.Enabled = model.Enabled
+ }
+ if model.ScheduleName != nil {
+ old.Name = model.ScheduleName
+ }
+ if model.Rrule != nil {
+ old.Rrule = model.Rrule
+ }
+
+ req = req.UpdateUpdateSchedulePayload(serverupdate.UpdateUpdateSchedulePayload{
+ Enabled: old.Enabled,
+ Name: old.Name,
+ Rrule: old.Rrule,
+ MaintenanceWindow: old.MaintenanceWindow,
+ })
+ return req, nil
+}
+
+func outputResult(p *print.Printer, outputFormat string, resp serverupdate.UpdateSchedule) error {
+ 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
new file mode 100644
index 000000000..e3cf7c7ab
--- /dev/null
+++ b/internal/cmd/server/os-update/schedule/update/update_test.go
@@ -0,0 +1,298 @@
+package update
+
+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/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/serverupdate"
+)
+
+const (
+ testRegion = "eu02"
+ testScheduleId = "5"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &serverupdate.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testScheduleId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ nameFlag: "example-schedule-name",
+ enabledFlag: "true",
+ rruleFlag: defaultRrule,
+ maintenanceWindowFlag: "23",
+ }
+ 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,
+ },
+ ScheduleId: testScheduleId,
+ ServerId: testServerId,
+ ScheduleName: utils.Ptr("example-schedule-name"),
+ Enabled: utils.Ptr(defaultEnabled),
+ Rrule: utils.Ptr(defaultRrule),
+ MaintenanceWindow: utils.Ptr(int64(23)),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureUpdateSchedule(mods ...func(schedule *serverupdate.UpdateSchedule)) *serverupdate.UpdateSchedule {
+ id, _ := strconv.ParseInt(testScheduleId, 10, 64)
+ schedule := &serverupdate.UpdateSchedule{
+ Name: utils.Ptr("example-schedule-name"),
+ Id: utils.Ptr(id),
+ Enabled: utils.Ptr(defaultEnabled),
+ Rrule: utils.Ptr(defaultRrule),
+ MaintenanceWindow: utils.Ptr(int64(23)),
+ }
+ for _, mod := range mods {
+ mod(schedule)
+ }
+ return schedule
+}
+
+func fixturePayload(mods ...func(payload *serverupdate.UpdateUpdateSchedulePayload)) serverupdate.UpdateUpdateSchedulePayload {
+ payload := serverupdate.UpdateUpdateSchedulePayload{
+ Name: utils.Ptr("example-schedule-name"),
+ Enabled: utils.Ptr(defaultEnabled),
+ Rrule: utils.Ptr("DTSTART;TZID=Europe/Sofia:20200803T023000 RRULE:FREQ=DAILY;INTERVAL=1"),
+ MaintenanceWindow: utils.Ptr(int64(23)),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *serverupdate.ApiUpdateUpdateScheduleRequest)) serverupdate.ApiUpdateUpdateScheduleRequest {
+ request := testClient.UpdateUpdateSchedule(testCtx, testProjectId, testServerId, testScheduleId, testRegion)
+ request = request.UpdateUpdateSchedulePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "no arg values",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "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: "schedule id invalid 1",
+ argValues: []string{""},
+ 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.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 flag groups: %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 serverupdate.ApiUpdateUpdateScheduleRequest
+ isValid bool
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ isValid: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ request, err := buildRequest(testCtx, tt.model, testClient, *fixtureUpdateSchedule())
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error building request: %v", err)
+ }
+
+ diff := cmp.Diff(request, tt.expectedRequest,
+ cmp.AllowUnexported(tt.expectedRequest),
+ cmpopts.EquateComparable(testCtx),
+ )
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ resp serverupdate.UpdateSchedule
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.resp); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/public-ip/attach/attach.go b/internal/cmd/server/public-ip/attach/attach.go
new file mode 100644
index 000000000..7532eac13
--- /dev/null
+++ b/internal/cmd/server/public-ip/attach/attach.go
@@ -0,0 +1,121 @@
+package attach
+
+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 (
+ publicIpIdArg = "PUBLIC_IP_ID"
+
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ PublicIpId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("attach %s", publicIpIdArg),
+ Short: "Attaches a public IP to a server",
+ Long: "Attaches a public IP to a server.",
+ Args: args.SingleArg(publicIpIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Attach a public IP with ID "xxx" to a server with ID "yyy"`,
+ `$ stackit server public-ip attach xxx --server-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
+ }
+
+ publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId)
+ if err != nil {
+ 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.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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("attach server to public ip: %w", err)
+ }
+
+ params.Printer.Info("Attached public IP %q to server %q\n", publicIpLabel, serverLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ PublicIpId: volumeId,
+ }
+
+ 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.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
new file mode 100644
index 000000000..4aef71f1f
--- /dev/null
+++ b/internal/cmd/server/public-ip/attach/attach_test.go
@@ -0,0 +1,232 @@
+package attach
+
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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 testPublicIpId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testPublicIpId,
+ }
+ 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,
+
+ serverIdFlag: testServerId,
+ }
+ 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,
+ PublicIpId: testPublicIpId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiAddPublicIpToServerRequest)) iaas.ApiAddPublicIpToServerRequest {
+ request := testClient.AddPublicIpToServer(testCtx, testProjectId, testRegion, testServerId, testPublicIpId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume id argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiAddPublicIpToServerRequest
+ }{
+ {
+ 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/public-ip/detach/detach.go b/internal/cmd/server/public-ip/detach/detach.go
new file mode 100644
index 000000000..8c85195e9
--- /dev/null
+++ b/internal/cmd/server/public-ip/detach/detach.go
@@ -0,0 +1,122 @@
+package detach
+
+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 (
+ publicIpIdArg = "PUBLIC_IP_ID"
+
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ PublicIpId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("detach %s", publicIpIdArg),
+ Short: "Detaches a public IP from a server",
+ Long: "Detaches a public IP from a server.",
+ Args: args.SingleArg(publicIpIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Detaches a public IP with ID "xxx" from a server with ID "yyy"`,
+ `$ stackit server public-ip detach xxx --server-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
+ }
+
+ publicIpLabel, _, err := iaasUtils.GetPublicIP(ctx, apiClient, model.ProjectId, model.Region, model.PublicIpId)
+ if err != nil {
+ 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.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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ if err := req.Execute(); err != nil {
+ return fmt.Errorf("detach public ip from server: %w", err)
+ }
+
+ params.Printer.Info("Detached public IP %q from server %q\n", publicIpLabel, serverLabel)
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ publicIpId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ PublicIpId: publicIpId,
+ }
+
+ 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.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
new file mode 100644
index 000000000..99251eae0
--- /dev/null
+++ b/internal/cmd/server/public-ip/detach/detach_test.go
@@ -0,0 +1,231 @@
+package detach
+
+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/iaas"
+)
+
+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 testPublicIpId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testPublicIpId,
+ }
+ 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,
+ serverIdFlag: testServerId,
+ }
+ 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,
+ PublicIpId: testPublicIpId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiRemovePublicIpFromServerRequest)) iaas.ApiRemovePublicIpFromServerRequest {
+ request := testClient.RemovePublicIpFromServer(testCtx, testProjectId, testRegion, testServerId, testPublicIpId)
+ 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",
+ 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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "public ip id argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiRemovePublicIpFromServerRequest
+ }{
+ {
+ 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/public-ip/public_ip.go b/internal/cmd/server/public-ip/public_ip.go
new file mode 100644
index 000000000..db04a0a67
--- /dev/null
+++ b/internal/cmd/server/public-ip/public_ip.go
@@ -0,0 +1,28 @@
+package publicip
+
+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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "public-ip",
+ Short: "Allows attaching/detaching public IPs to servers",
+ Long: "Allows attaching/detaching public IPs 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/reboot/reboot.go b/internal/cmd/server/reboot/reboot.go
new file mode 100644
index 000000000..bf689cfcc
--- /dev/null
+++ b/internal/cmd/server/reboot/reboot.go
@@ -0,0 +1,124 @@
+package reboot
+
+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/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"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+
+ hardRebootFlag = "hard"
+ defaultHardReboot = false
+ hardRebootAction = "hard"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ HardReboot bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("reboot %s", serverIdArg),
+ Short: "Reboots a server",
+ Long: "Reboots a server.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Perform a soft reboot of a server with ID "xxx"`,
+ "$ stackit server reboot xxx",
+ ),
+ examples.NewExample(
+ `Perform a hard reboot of a server with ID "xxx"`,
+ "$ stackit server reboot xxx --hard",
+ ),
+ ),
+ 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
+ }
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("server reboot: %w", err)
+ }
+
+ params.Printer.Info("Server %q rebooted\n", serverLabel)
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().BoolP(hardRebootFlag, "b", defaultHardReboot, "Performs a hard reboot. (default false)")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ HardReboot: flags.FlagToBoolValue(p, cmd, hardRebootFlag),
+ }
+
+ 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.Region, model.ServerId)
+ // if hard reboot is set the action must be set (soft is default)
+ if model.HardReboot {
+ req = req.Action(hardRebootAction)
+ }
+ return req
+}
diff --git a/internal/cmd/server/reboot/reboot_test.go b/internal/cmd/server/reboot/reboot_test.go
new file mode 100644
index 000000000..1a05016e1
--- /dev/null
+++ b/internal/cmd/server/reboot/reboot_test.go
@@ -0,0 +1,177 @@
+package reboot
+
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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,
+
+ hardRebootFlag: "false",
+ }
+ 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,
+ HardReboot: false,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiRebootServerRequest)) iaas.ApiRebootServerRequest {
+ request := testClient.RebootServer(testCtx, testProjectId, testRegion, testServerId)
+ 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: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "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.ApiRebootServerRequest
+ }{
+ {
+ description: "base (soft reboot)",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "hard reboot is set",
+ model: fixtureInputModel(func(model *inputModel) {
+ model.HardReboot = true
+ }),
+ expectedRequest: fixtureRequest(func(request *iaas.ApiRebootServerRequest) {
+ *request = (*request).Action("hard")
+ }),
+ },
+ }
+
+ 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/rescue/rescue.go b/internal/cmd/server/rescue/rescue.go
new file mode 100644
index 000000000..dd77e4625
--- /dev/null
+++ b/internal/cmd/server/rescue/rescue.go
@@ -0,0 +1,138 @@
+package rescue
+
+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/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 (
+ serverIdArg = "SERVER_ID"
+
+ imageIdFlag = "image-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ ImageId *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("rescue %s", serverIdArg),
+ Short: "Rescues an existing server",
+ Long: "Rescues an existing server.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Rescue an existing server with ID "xxx" using image with ID "yyy" as boot volume`,
+ "$ stackit server rescue xxx --image-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
+ }
+
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("server rescue: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Rescuing server")
+ _, err = wait.RescueServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for server rescuing: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Rescued"
+ if model.Async {
+ operationState = "Triggered rescue of"
+ }
+ params.Printer.Info("%s server %q. Image %q is used as temporary boot image\n", operationState, serverLabel, utils.PtrString(model.ImageId))
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), imageIdFlag, "The image ID to be used for a temporary boot volume.")
+
+ err := flags.MarkFlagsRequired(cmd, imageIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ ImageId: flags.FlagToStringPointer(p, cmd, imageIdFlag),
+ }
+
+ 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.Region, model.ServerId)
+ payload := iaas.RescueServerPayload{
+ Image: model.ImageId,
+ }
+ return req.RescueServerPayload(payload)
+}
diff --git a/internal/cmd/server/rescue/rescue_test.go b/internal/cmd/server/rescue/rescue_test.go
new file mode 100644
index 000000000..b7ec93e90
--- /dev/null
+++ b/internal/cmd/server/rescue/rescue_test.go
@@ -0,0 +1,183 @@
+package rescue
+
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testImageId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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,
+
+ imageIdFlag: testImageId,
+ }
+ 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,
+ ImageId: utils.Ptr(testImageId),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiRescueServerRequest)) iaas.ApiRescueServerRequest {
+ request := testClient.RescueServer(testCtx, testProjectId, testRegion, testServerId)
+ request = request.RescueServerPayload(iaas.RescueServerPayload{
+ Image: utils.Ptr(testImageId),
+ })
+ 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: "required image id flag missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, imageIdFlag)
+ }),
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "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.ApiRescueServerRequest
+ }{
+ {
+ 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/resize/resize.go b/internal/cmd/server/resize/resize.go
new file mode 100644
index 000000000..804dbce1a
--- /dev/null
+++ b/internal/cmd/server/resize/resize.go
@@ -0,0 +1,138 @@
+package resize
+
+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/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 (
+ serverIdArg = "SERVER_ID"
+
+ machineTypeFlag = "machine-type"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ MachineType *string
+}
+
+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",
+ Long: "Resizes the server to the given machine type.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Resize a server with ID "xxx" to machine type "yyy"`,
+ "$ stackit server resize xxx --machine-type 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
+ }
+
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("server resize: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Resizing server")
+ _, err = wait.ResizeServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for server resizing: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Resized"
+ if model.Async {
+ operationState = "Triggered resize of"
+ }
+ params.Printer.Info("%s server %q\n", operationState, serverLabel)
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+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/products/compute-engine/server/basics/machine-types/")
+
+ err := flags.MarkFlagsRequired(cmd, machineTypeFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ MachineType: flags.FlagToStringPointer(p, cmd, machineTypeFlag),
+ }
+
+ 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.Region, model.ServerId)
+ payload := iaas.ResizeServerPayload{
+ MachineType: model.MachineType,
+ }
+ return req.ResizeServerPayload(payload)
+}
diff --git a/internal/cmd/server/resize/resize_test.go b/internal/cmd/server/resize/resize_test.go
new file mode 100644
index 000000000..3ba39088f
--- /dev/null
+++ b/internal/cmd/server/resize/resize_test.go
@@ -0,0 +1,182 @@
+package resize
+
+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"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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,
+
+ machineTypeFlag: "t1.2",
+ }
+ 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,
+ MachineType: utils.Ptr("t1.2"),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiResizeServerRequest)) iaas.ApiResizeServerRequest {
+ request := testClient.ResizeServer(testCtx, testProjectId, testRegion, testServerId)
+ request = request.ResizeServerPayload(iaas.ResizeServerPayload{
+ MachineType: utils.Ptr("t1.2"),
+ })
+ 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: "required machine type flag missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, machineTypeFlag)
+ }),
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "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.ApiResizeServerRequest
+ }{
+ {
+ 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/server.go b/internal/cmd/server/server.go
new file mode 100644
index 000000000..e671fda2b
--- /dev/null
+++ b/internal/cmd/server/server.go
@@ -0,0 +1,69 @@
+package server
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/backup"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/command"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/console"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/deallocate"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/list"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/log"
+ machinetype "github.com/stackitcloud/stackit-cli/internal/cmd/server/machine-type"
+ networkinterface "github.com/stackitcloud/stackit-cli/internal/cmd/server/network-interface"
+ osUpdate "github.com/stackitcloud/stackit-cli/internal/cmd/server/os-update"
+ publicip "github.com/stackitcloud/stackit-cli/internal/cmd/server/public-ip"
+ "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"
+ 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/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "server",
+ Short: "Provides functionality for servers",
+ Long: "Provides functionality for servers.",
+ Args: args.NoArgs,
+ Run: utils.CmdHelp,
+ }
+ addSubcommands(cmd, params)
+ return cmd
+}
+
+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(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
new file mode 100644
index 000000000..e7c7ea762
--- /dev/null
+++ b/internal/cmd/server/service-account/attach/attach.go
@@ -0,0 +1,120 @@
+package attach
+
+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/services/iaas/client"
+ iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ serviceAccMailArg = "SERVICE_ACCOUNT_EMAIL"
+
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ ServiceAccMail string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("attach %s", serviceAccMailArg),
+ Short: "Attach a service account to a server",
+ Long: "Attach a service account to a server",
+ Args: args.SingleArg(serviceAccMailArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Attach a service account with mail "xxx@sa.stackit.cloud" to a server with ID "yyy"`,
+ "$ stackit server service-account attach xxx@sa.stackit.cloud --server-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
+ }
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("attach service account to server: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.ServiceAccMail, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serviceAccMail := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ ServiceAccMail: serviceAccMail,
+ }
+
+ 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.Region, model.ServerId, model.ServiceAccMail)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat, serviceAccMail, serverLabel string, serviceAccounts iaas.ServiceAccountMailListResponse) error {
+ 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
new file mode 100644
index 000000000..b34109960
--- /dev/null
+++ b/internal/cmd/server/service-account/attach/attach_test.go
@@ -0,0 +1,261 @@
+package attach
+
+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-sdk-go/services/iaas"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), &testCtxKey{}, "test")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testServiceAccount = "test@example.com"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServiceAccount,
+ }
+ 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,
+
+ serverIdFlag: testServerId,
+ }
+ 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,
+ ServiceAccMail: testServiceAccount,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiAddServiceAccountToServerRequest)) iaas.ApiAddServiceAccountToServerRequest {
+ request := testClient.AddServiceAccountToServer(testCtx, testProjectId, testRegion, testServerId, testServiceAccount)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "service account argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiAddServiceAccountToServerRequest
+ }{
+ {
+ 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
+ serviceAccMail string
+ serverLabel string
+ serviceAccounts iaas.ServiceAccountMailListResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serviceAccMail, tt.args.serverLabel, tt.args.serviceAccounts); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/service-account/detach/detach.go b/internal/cmd/server/service-account/detach/detach.go
new file mode 100644
index 000000000..00830e7d6
--- /dev/null
+++ b/internal/cmd/server/service-account/detach/detach.go
@@ -0,0 +1,120 @@
+package detach
+
+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/services/iaas/client"
+ iaasUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils"
+
+ "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ serviceAccMailArg = "SERVICE_ACCOUNT_EMAIL"
+
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ ServiceAccMail string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("detach %s", serviceAccMailArg),
+ Short: "Detach a service account from a server",
+ Long: "Detach a service account from a server",
+ Args: args.SingleArg(serviceAccMailArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Detach a service account with mail "xxx@sa.stackit.cloud" from a server "yyy"`,
+ "$ stackit server service-account detach xxx@sa.stackit.cloud --server-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
+ }
+ 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
+ }
+
+ prompt := fmt.Sprintf("Are your 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("detach service account request: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.ServiceAccMail, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server id")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serviceAccMail := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ ServiceAccMail: serviceAccMail,
+ }
+
+ 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.Region, model.ServerId, model.ServiceAccMail)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat, serviceAccMail, serverLabel string, service iaas.ServiceAccountMailListResponse) error {
+ 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
new file mode 100644
index 000000000..0867408f1
--- /dev/null
+++ b/internal/cmd/server/service-account/detach/detach_test.go
@@ -0,0 +1,261 @@
+package detach
+
+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-sdk-go/services/iaas"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/google/uuid"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), &testCtxKey{}, "test")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testServiceAccount = "test@example.com"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServiceAccount,
+ }
+ 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,
+
+ serverIdFlag: testServerId,
+ }
+ 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,
+ ServiceAccMail: testServiceAccount,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiRemoveServiceAccountFromServerRequest)) iaas.ApiRemoveServiceAccountFromServerRequest {
+ request := testClient.RemoveServiceAccountFromServer(testCtx, testProjectId, testRegion, testServerId, testServiceAccount)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "service account argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiRemoveServiceAccountFromServerRequest
+ }{
+ {
+ 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
+ serviceAccMail string
+ serverLabel string
+ service iaas.ServiceAccountMailListResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serviceAccMail, tt.args.serverLabel, tt.args.service); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/service-account/list/list.go b/internal/cmd/server/service-account/list/list.go
new file mode 100644
index 000000000..a8188b65b
--- /dev/null
+++ b/internal/cmd/server/service-account/list/list.go
@@ -0,0 +1,147 @@
+package list
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ serverIdFlag = "server-id"
+ limitFlag = "limit"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ Limit *int64
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List all attached service accounts for a server",
+ Long: "List all attached service accounts for a server",
+ Args: cobra.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all attached service accounts for a server with ID "xxx"`,
+ "$ stackit server service-account list --server-id xxx",
+ ),
+ examples.NewExample(
+ `List up to 10 attached service accounts for a server with ID "xxx"`,
+ "$ stackit server service-account list --server-id xxx --limit 10",
+ ),
+ examples.NewExample(
+ `List all attached service accounts for a server with ID "xxx" in JSON format`,
+ "$ stackit server service-account list --server-id 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
+ }
+
+ serverName, err := iaasUtils.GetServerName(ctx, apiClient, model.ProjectId, model.Region, model.ServerId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get server name: %v", err)
+ serverName = model.ServerId
+ } else if serverName == "" {
+ serverName = model.ServerId
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("list service accounts: %w", err)
+ }
+ serviceAccounts := *resp.Items
+ if len(serviceAccounts) == 0 {
+ params.Printer.Info("No service accounts found for server %s\n", serverName)
+ return nil
+ }
+
+ if model.Limit != nil && len(serviceAccounts) > int(*model.Limit) {
+ serviceAccounts = serviceAccounts[:int(*model.Limit)]
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, model.ServerId, serverName, serviceAccounts)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+ cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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,
+ 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.Region, model.ServerId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat, serverId, serverName string, serviceAccounts []string) error {
+ return p.OutputResult(outputFormat, serviceAccounts, func() error {
+ table := tables.NewTable()
+ table.SetHeader("SERVER ID", "SERVER NAME", "SERVICE ACCOUNT")
+ for i := range serviceAccounts {
+ table.AddRow(serverId, serverName, serviceAccounts[i])
+ }
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..a239a9392
--- /dev/null
+++ b/internal/cmd/server/service-account/list/list_test.go
@@ -0,0 +1,229 @@
+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/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), &testCtxKey{}, "test")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+var testLimit = int64(10)
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ serverIdFlag: testServerId,
+ limitFlag: strconv.FormatInt(testLimit, 10),
+ }
+ for _, mod := range mods {
+ mod(flagValues)
+ }
+ return flagValues
+}
+
+func fixtureInputModel(mods ...func(inputModel *inputModel)) *inputModel {
+ model := &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ ServerId: testServerId,
+ Limit: utils.Ptr(testLimit),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListServerServiceAccountsRequest)) iaas.ApiListServerServiceAccountsRequest {
+ request := testClient.ListServerServiceAccounts(testCtx, testProjectId, testRegion, testServerId)
+ 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: "server id 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,
+ },
+ {
+ description: "without limit",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, limitFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Limit = nil
+ }),
+ },
+ {
+ description: "limit invalid 1",
+ 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.ApiListServerServiceAccountsRequest
+ }{
+ {
+ 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("Request does not match: %s", diff)
+ }
+ })
+ }
+}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ serverId string
+ serverName string
+ serviceAccounts []string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty service account",
+ args: args{
+ serviceAccounts: []string{
+ "",
+ },
+ },
+ 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.serverId, tt.args.serverName, tt.args.serviceAccounts); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/service-account/service-account.go b/internal/cmd/server/service-account/service-account.go
new file mode 100644
index 000000000..6bb4576ba
--- /dev/null
+++ b/internal/cmd/server/service-account/service-account.go
@@ -0,0 +1,30 @@
+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/utils"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "service-account",
+ Short: "Allows attaching/detaching service accounts to servers",
+ Long: "Allows attaching/detaching service accounts to servers",
+ Args: cobra.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))
+ cmd.AddCommand(list.NewCmd(params))
+}
diff --git a/internal/cmd/server/start/start.go b/internal/cmd/server/start/start.go
new file mode 100644
index 000000000..3dc23ff31
--- /dev/null
+++ b/internal/cmd/server/start/start.go
@@ -0,0 +1,115 @@
+package start
+
+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/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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+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",
+ Long: "Starts an existing server or allocates the server if deallocated.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Start an existing server with ID "xxx"`,
+ "$ stackit server start 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
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("server start: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Starting server")
+ _, err = wait.StartServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for server starting: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Started"
+ if model.Async {
+ operationState = "Triggered start of"
+ }
+ params.Printer.Info("%s server %q\n", operationState, serverLabel)
+
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ }
+
+ 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.Region, model.ServerId)
+}
diff --git a/internal/cmd/server/start/start_test.go b/internal/cmd/server/start/start_test.go
new file mode 100644
index 000000000..f5e64b2d2
--- /dev/null
+++ b/internal/cmd/server/start/start_test.go
@@ -0,0 +1,165 @@
+package start
+
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiStartServerRequest)) iaas.ApiStartServerRequest {
+ request := testClient.StartServer(testCtx, testProjectId, testRegion, testServerId)
+ 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: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "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.ApiStartServerRequest
+ }{
+ {
+ 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/stop/stop.go b/internal/cmd/server/stop/stop.go
new file mode 100644
index 000000000..a7a9b4604
--- /dev/null
+++ b/internal/cmd/server/stop/stop.go
@@ -0,0 +1,121 @@
+package stop
+
+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/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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("stop %s", serverIdArg),
+ Short: "Stops an existing server",
+ Long: "Stops an existing server.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Stop an existing server with ID "xxx"`,
+ "$ stackit server stop 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("server stop: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Stopping server")
+ _, err = wait.StopServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for server stopping: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Stopped"
+ if model.Async {
+ operationState = "Triggered stop of"
+ }
+ params.Printer.Info("%s server %q\n", operationState, serverLabel)
+
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ }
+
+ 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.Region, model.ServerId)
+}
diff --git a/internal/cmd/server/stop/stop_test.go b/internal/cmd/server/stop/stop_test.go
new file mode 100644
index 000000000..29980a4b9
--- /dev/null
+++ b/internal/cmd/server/stop/stop_test.go
@@ -0,0 +1,165 @@
+package stop
+
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiStopServerRequest)) iaas.ApiStopServerRequest {
+ request := testClient.StopServer(testCtx, testProjectId, testRegion, testServerId)
+ 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: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "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.ApiStopServerRequest
+ }{
+ {
+ 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/unrescue/unrescue.go b/internal/cmd/server/unrescue/unrescue.go
new file mode 100644
index 000000000..f47ef6794
--- /dev/null
+++ b/internal/cmd/server/unrescue/unrescue.go
@@ -0,0 +1,121 @@
+package unrescue
+
+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/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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("unrescue %s", serverIdArg),
+ Short: "Unrescues an existing server",
+ Long: "Unrescues an existing server.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Unrescue an existing server with ID "xxx"`,
+ "$ stackit server unrescue 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ err = req.Execute()
+ if err != nil {
+ return fmt.Errorf("server unrescue: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Unrescuing server")
+ _, err = wait.UnrescueServerWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ServerId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for server unrescuing: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Unrescued"
+ if model.Async {
+ operationState = "Triggered unrescue of"
+ }
+ params.Printer.Info("%s server %q\n", operationState, serverLabel)
+
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: serverId,
+ }
+
+ 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.Region, model.ServerId)
+}
diff --git a/internal/cmd/server/unrescue/unrescue_test.go b/internal/cmd/server/unrescue/unrescue_test.go
new file mode 100644
index 000000000..708fe68d8
--- /dev/null
+++ b/internal/cmd/server/unrescue/unrescue_test.go
@@ -0,0 +1,165 @@
+package unrescue
+
+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")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testServerId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ ServerId: testServerId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUnrescueServerRequest)) iaas.ApiUnrescueServerRequest {
+ request := testClient.UnrescueServer(testCtx, testProjectId, testRegion, testServerId)
+ 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: "project id missing",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ 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: "server id invalid 1",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = ""
+ }),
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(func(argValues []string) {
+ argValues[0] = "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.ApiUnrescueServerRequest
+ }{
+ {
+ 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/update/update.go b/internal/cmd/server/update/update.go
new file mode 100644
index 000000000..7349d1c28
--- /dev/null
+++ b/internal/cmd/server/update/update.go
@@ -0,0 +1,133 @@
+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"
+ 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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ serverIdArg = "SERVER_ID"
+
+ nameFlag = "name"
+ labelFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ Name *string
+ Labels *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", serverIdArg),
+ Short: "Updates a server",
+ Long: "Updates a server.",
+ Args: args.SingleArg(serverIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update server with ID "xxx" with new name "server-1-new"`,
+ `$ stackit server update xxx --name server-1-new`,
+ ),
+ examples.NewExample(
+ `Update server with ID "xxx" with new name "server-1-new" and label(s)`,
+ `$ stackit server update xxx --name server-1-new --labels key=value,foo=bar`,
+ ),
+ ),
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update server: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(nameFlag, "n", "", "Server name")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a server. E.g. '--labels key1=value1,key2=value2,...'")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ serverId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ ServerId: serverId,
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ }
+
+ 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.Region, model.ServerId)
+
+ payload := iaas.UpdateServerPayload{
+ Name: model.Name,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+
+ return req.UpdateServerPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel string, server *iaas.Server) error {
+ 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
new file mode 100644
index 000000000..7aea4f1c1
--- /dev/null
+++ b/internal/cmd/server/update/update_test.go
@@ -0,0 +1,287 @@
+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/iaas"
+)
+
+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()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testServerId,
+ }
+ 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: "example-server-name",
+ labelFlag: "key=value",
+ }
+ 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,
+ },
+ Name: utils.Ptr("example-server-name"),
+ ServerId: testServerId,
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdateServerRequest)) iaas.ApiUpdateServerRequest {
+ request := testClient.UpdateServer(testCtx, testProjectId, testRegion, testServerId)
+ request = request.UpdateServerPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdateServerPayload)) iaas.UpdateServerPayload {
+ payload := iaas.UpdateServerPayload{
+ Name: utils.Ptr("example-server-name"),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ }
+ 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: "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: "server id invalid 1",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "use name",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = "example-server-name"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Name = utils.Ptr("example-server-name")
+ }),
+ },
+ {
+ description: "use labels",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelFlag] = "key=value"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = &map[string]string{
+ "key": "value",
+ }
+ }),
+ },
+ }
+
+ 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 iaas.ApiUpdateServerRequest
+ }{
+ {
+ 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
+ serverLabel string
+ server *iaas.Server
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serverLabel, tt.args.server); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/volume/attach/attach.go b/internal/cmd/server/volume/attach/attach.go
new file mode 100644
index 000000000..27c734f8d
--- /dev/null
+++ b/internal/cmd/server/volume/attach/attach.go
@@ -0,0 +1,140 @@
+package attach
+
+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/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 (
+ volumeIdArg = "VOLUME_ID"
+
+ serverIdFlag = "server-id"
+ deleteOnTerminationFlag = "delete-on-termination"
+
+ defaultDeleteOnTermination = false
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ VolumeId string
+ DeleteOnTermination *bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("attach %s", volumeIdArg),
+ Short: "Attaches a volume to a server",
+ Long: "Attaches a volume to a server.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Attach a volume with ID "xxx" to a server with ID "yyy"`,
+ `$ stackit server volume attach xxx --server-id yyy`,
+ ),
+ examples.NewExample(
+ `Attach a volume with ID "xxx" to a server with ID "yyy" and enable deletion on termination`,
+ `$ stackit server volume attach xxx --server-id yyy --delete-on-termination`,
+ ),
+ ),
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("attach server volume: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, volumeLabel, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID")
+ cmd.Flags().BoolP(deleteOnTerminationFlag, "b", defaultDeleteOnTermination, "Delete the volume during the termination of the server. (default false)")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ DeleteOnTermination: flags.FlagToBoolPointer(p, cmd, deleteOnTerminationFlag),
+ VolumeId: volumeId,
+ }
+
+ 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.Region, model.ServerId, model.VolumeId)
+ payload := iaas.AddVolumeToServerPayload{
+ DeleteOnTermination: model.DeleteOnTermination,
+ }
+ return req.AddVolumeToServerPayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, volumeLabel, serverLabel string, volume iaas.VolumeAttachment) error {
+ 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
new file mode 100644
index 000000000..69bffcfee
--- /dev/null
+++ b/internal/cmd/server/volume/attach/attach_test.go
@@ -0,0 +1,287 @@
+package attach
+
+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/iaas"
+)
+
+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 testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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,
+
+ serverIdFlag: testServerId,
+ deleteOnTerminationFlag: "true",
+ }
+ 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,
+ VolumeId: testVolumeId,
+ DeleteOnTermination: utils.Ptr(true),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixturePayload(mods ...func(payload *iaas.AddVolumeToServerPayload)) iaas.AddVolumeToServerPayload {
+ payload := iaas.AddVolumeToServerPayload{
+ DeleteOnTermination: utils.Ptr(true),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiAddVolumeToServerRequest)) iaas.ApiAddVolumeToServerRequest {
+ request := testClient.AddVolumeToServer(testCtx, testProjectId, testRegion, testServerId, testVolumeId)
+ request = request.AddVolumeToServerPayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume id argument missing",
+ argValues: []string{},
+ isValid: false,
+ },
+ {
+ description: "required only",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, deleteOnTerminationFlag)
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.DeleteOnTermination = nil
+ }),
+ isValid: true,
+ },
+ }
+
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiAddVolumeToServerRequest
+ }{
+ {
+ 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
+ volumeLabel string
+ serverLabel string
+ volume iaas.VolumeAttachment
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.volumeLabel, tt.args.serverLabel, tt.args.volume); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/volume/describe/describe.go b/internal/cmd/server/volume/describe/describe.go
new file mode 100644
index 000000000..385b585bf
--- /dev/null
+++ b/internal/cmd/server/volume/describe/describe.go
@@ -0,0 +1,147 @@
+package describe
+
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ VolumeId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", volumeIdArg),
+ Short: "Describes a server volume attachment",
+ Long: "Describes a server volume attachment.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Get details of the attachment of volume with ID "xxx" to server with ID "yyy"`,
+ `$ stackit server volume describe xxx --server-id yyy`,
+ ),
+ examples.NewExample(
+ `Get details of the attachment of volume with ID "xxx" to server with ID "yyy" in JSON format`,
+ `$ stackit server volume describe xxx --server-id yyy --output-format json`,
+ ),
+ examples.NewExample(
+ `Get details of the attachment of volume with ID "xxx" to server with ID "yyy" in yaml format`,
+ `$ stackit server volume describe xxx --server-id yyy --output-format yaml`,
+ ),
+ ),
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("describe server volume: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, volumeLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ VolumeId: volumeId,
+ }
+
+ 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.Region, model.ServerId, model.VolumeId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel, volumeLabel string, volume iaas.VolumeAttachment) error {
+ return p.OutputResult(outputFormat, volume, func() error {
+ table := tables.NewTable()
+ table.AddRow("SERVER ID", utils.PtrString(volume.ServerId))
+ table.AddSeparator()
+ table.AddRow("SERVER NAME", serverLabel)
+ table.AddSeparator()
+ table.AddRow("VOLUME ID", utils.PtrString(volume.VolumeId))
+ table.AddSeparator()
+ // check if name is set
+ if volumeLabel != "" {
+ table.AddRow("VOLUME NAME", volumeLabel)
+ table.AddSeparator()
+ }
+ table.AddRow("DELETE ON TERMINATION", utils.PtrString(volume.DeleteOnTermination))
+ table.AddSeparator()
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..2e88ae1c4
--- /dev/null
+++ b/internal/cmd/server/volume/describe/describe_test.go
@@ -0,0 +1,261 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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 testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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,
+
+ serverIdFlag: testServerId,
+ }
+ 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,
+ VolumeId: testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetAttachedVolumeRequest)) iaas.ApiGetAttachedVolumeRequest {
+ request := testClient.GetAttachedVolume(testCtx, testProjectId, testRegion, testServerId, testVolumeId)
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func TestParseInput(t *testing.T) {
+ tests := []struct {
+ description string
+ argValues []string
+ flagValues map[string]string
+ isValid bool
+ expectedModel *inputModel
+ }{
+ {
+ description: "base",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(),
+ isValid: true,
+ expectedModel: fixtureInputModel(),
+ },
+ {
+ description: "no values",
+ argValues: fixtureArgValues(),
+ flagValues: map[string]string{},
+ isValid: false,
+ },
+ {
+ description: "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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume id argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiGetAttachedVolumeRequest
+ }{
+ {
+ 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
+ serverLabel string
+ volumeLabel string
+ volume iaas.VolumeAttachment
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.serverLabel, tt.args.volumeLabel, tt.args.volume); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/volume/detach/detach.go b/internal/cmd/server/volume/detach/detach.go
new file mode 100644
index 000000000..0c8081a5d
--- /dev/null
+++ b/internal/cmd/server/volume/detach/detach.go
@@ -0,0 +1,121 @@
+package detach
+
+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/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 (
+ volumeIdArg = "VOLUME_ID"
+
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ VolumeId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("detach %s", volumeIdArg),
+ Short: "Detaches a volume from a server",
+ Long: "Detaches a volume from a server.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Detaches a volume with ID "xxx" from a server with ID "yyy"`,
+ `$ stackit server volume detach xxx --server-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
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ if err := req.Execute(); err != nil {
+ return fmt.Errorf("detach server volume: %w", err)
+ }
+
+ params.Printer.Info("Detached volume %q from server %q\n", volumeLabel, serverLabel)
+
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ VolumeId: volumeId,
+ }
+
+ 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.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
new file mode 100644
index 000000000..a9b5843b1
--- /dev/null
+++ b/internal/cmd/server/volume/detach/detach_test.go
@@ -0,0 +1,231 @@
+package detach
+
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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 testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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,
+
+ serverIdFlag: testServerId,
+ }
+ 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,
+ VolumeId: testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiRemoveVolumeFromServerRequest)) iaas.ApiRemoveVolumeFromServerRequest {
+ request := testClient.RemoveVolumeFromServer(testCtx, testProjectId, testRegion, testServerId, testVolumeId)
+ 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",
+ 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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "volume id argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiRemoveVolumeFromServerRequest
+ }{
+ {
+ 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/volume/list/list.go b/internal/cmd/server/volume/list/list.go
new file mode 100644
index 000000000..10df56261
--- /dev/null
+++ b/internal/cmd/server/volume/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-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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ serverIdFlag = "server-id"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists all server volumes",
+ Long: "Lists all server volumes.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `List all volumes for a server with ID "xxx"`,
+ "$ stackit server volume list --server-id xxx"),
+ examples.NewExample(
+ `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, 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
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("list server volumes: %w", err)
+ }
+ volumes := *resp.Items
+ if len(volumes) == 0 {
+ 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, model.Region, *volumes[i].VolumeId)
+ if err != nil {
+ params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err)
+ volumeLabel = ""
+ }
+ volumeNames = append(volumeNames, volumeLabel)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, serverLabel, volumeNames, volumes)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().VarP(flags.UUIDFlag(), serverIdFlag, "s", "Server ID")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ 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),
+ }
+
+ 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.Region, model.ServerId)
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat, serverLabel string, volumeNames []string, volumes []iaas.VolumeAttachment) error {
+ return p.OutputResult(outputFormat, volumes, func() error {
+ table := tables.NewTable()
+ table.SetHeader("SERVER ID", "SERVER NAME", "VOLUME ID", "VOLUME NAME")
+ for i := range volumes {
+ s := volumes[i]
+ var volumeName string
+ if len(volumeNames)-1 > i {
+ volumeName = volumeNames[i]
+ }
+ table.AddRow(utils.PtrString(s.ServerId), serverLabel, utils.PtrString(s.VolumeId), volumeName)
+ }
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..ea65dc2cb
--- /dev/null
+++ b/internal/cmd/server/volume/list/list_test.go
@@ -0,0 +1,199 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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()
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ serverIdFlag: testServerId,
+ }
+ 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,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListAttachedVolumesRequest)) iaas.ApiListAttachedVolumesRequest {
+ request := testClient.ListAttachedVolumes(testCtx, testProjectId, testRegion, testServerId)
+ 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: "server id 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, tt.argValues, tt.flagValues, tt.isValid)
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiListAttachedVolumesRequest
+ }{
+ {
+ 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
+ serverLabel string
+ volumeNames []string
+ volumes []iaas.VolumeAttachment
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty volume",
+ args: args{
+ volumes: []iaas.VolumeAttachment{
+ {},
+ },
+ },
+ 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.serverLabel, tt.args.volumeNames, tt.args.volumes); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/volume/update/update.go b/internal/cmd/server/volume/update/update.go
new file mode 100644
index 000000000..4ba1a9342
--- /dev/null
+++ b/internal/cmd/server/volume/update/update.go
@@ -0,0 +1,136 @@
+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"
+ 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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+
+ serverIdFlag = "server-id"
+ deleteOnTerminationFlag = "delete-on-termination"
+
+ defaultDeleteOnTermination = false
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ServerId string
+ VolumeId string
+ DeleteOnTermination *bool
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", volumeIdArg),
+ Short: "Updates an attached volume of a server",
+ Long: "Updates an attached volume of a server.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update a volume with ID "xxx" of a server with ID "yyy" and enables delete on termination`,
+ `$ stackit server volume update xxx --server-id yyy --delete-on-termination`,
+ ),
+ ),
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ 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
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("update server volume: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, volumeLabel, serverLabel, *resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Var(flags.UUIDFlag(), serverIdFlag, "Server ID")
+ cmd.Flags().BoolP(deleteOnTerminationFlag, "b", defaultDeleteOnTermination, "Delete the volume during the termination of the server. (default false)")
+
+ err := flags.MarkFlagsRequired(cmd, serverIdFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ ServerId: flags.FlagToStringValue(p, cmd, serverIdFlag),
+ DeleteOnTermination: flags.FlagToBoolPointer(p, cmd, deleteOnTerminationFlag),
+ VolumeId: volumeId,
+ }
+
+ 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.Region, model.ServerId, model.VolumeId)
+ payload := iaas.UpdateAttachedVolumePayload{
+ DeleteOnTermination: model.DeleteOnTermination,
+ }
+ return req.UpdateAttachedVolumePayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, volumeLabel, serverLabel string, volume iaas.VolumeAttachment) error {
+ 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
new file mode 100644
index 000000000..532b37d5a
--- /dev/null
+++ b/internal/cmd/server/volume/update/update_test.go
@@ -0,0 +1,286 @@
+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/iaas"
+)
+
+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 testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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,
+
+ serverIdFlag: testServerId,
+ deleteOnTerminationFlag: "true",
+ }
+ 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,
+ VolumeId: testVolumeId,
+ DeleteOnTermination: utils.Ptr(true),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdateAttachedVolumePayload)) iaas.UpdateAttachedVolumePayload {
+ payload := iaas.UpdateAttachedVolumePayload{
+ DeleteOnTermination: utils.Ptr(true),
+ }
+ for _, mod := range mods {
+ mod(&payload)
+ }
+ return payload
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdateAttachedVolumeRequest)) iaas.ApiUpdateAttachedVolumeRequest {
+ request := testClient.UpdateAttachedVolume(testCtx, testProjectId, testRegion, testServerId, testVolumeId)
+ request = request.UpdateAttachedVolumePayload(fixturePayload())
+ 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",
+ 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: "server id missing",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, serverIdFlag)
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 1",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = ""
+ }),
+ isValid: false,
+ },
+ {
+ description: "server id invalid 2",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[serverIdFlag] = "invalid-uuid"
+ }),
+ isValid: false,
+ },
+ {
+ description: "required only",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, deleteOnTerminationFlag)
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.DeleteOnTermination = nil
+ }),
+ isValid: true,
+ },
+ {
+ description: "volume id argument missing",
+ argValues: []string{},
+ 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 parsing 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)
+ }
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiUpdateAttachedVolumeRequest
+ }{
+ {
+ 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
+ volumeLabel string
+ serverLabel string
+ volume iaas.VolumeAttachment
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ 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.volumeLabel, tt.args.serverLabel, tt.args.volume); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/server/volume/volume.go b/internal/cmd/server/volume/volume.go
new file mode 100644
index 000000000..444ef040b
--- /dev/null
+++ b/internal/cmd/server/volume/volume.go
@@ -0,0 +1,34 @@
+package volume
+
+import (
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/volume/attach"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/volume/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/server/volume/detach"
+ "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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "volume",
+ Short: "Provides functionality for server volumes",
+ Long: "Provides functionality for server volumes.",
+ 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))
+ 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 5314543b8..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"
@@ -14,6 +14,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/service-account/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/serviceaccount"
@@ -28,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",
@@ -41,29 +42,27 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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
@@ -73,7 +72,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("create service account: %w", err)
}
- return outputResult(p, model, projectLabel, resp)
+ return outputResult(params.Printer, model.OutputFormat, projectLabel, resp)
},
}
configureFlags(cmd)
@@ -87,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{}
@@ -98,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
}
@@ -118,26 +109,13 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceacco
return req
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel string, serviceAccount *serviceaccount.ServiceAccount) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal service account: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, projectLabel string, serviceAccount *serviceaccount.ServiceAccount) error {
+ if serviceAccount == nil {
+ return fmt.Errorf("service account is nil")
+ }
+ 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
- default:
- p.Outputf("Created service account for project %q. Email: %s\n", projectLabel, *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 5e8584495..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)
})
}
}
@@ -187,3 +152,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ projectLabel string
+ serviceAccount *serviceaccount.ServiceAccount
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty service account",
+ args: args{
+ serviceAccount: &serviceaccount.ServiceAccount{},
+ },
+ 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.serviceAccount); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 6ea2ee487..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)
@@ -137,3 +139,43 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ serviceAccounts []serviceaccount.JWK
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty service accounts slice",
+ args: args{
+ serviceAccounts: []serviceaccount.JWK{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty service account in service accounts slice",
+ args: args{
+ serviceAccounts: []serviceaccount.JWK{{}},
+ },
+ 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.serviceAccounts); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/service-account/key/create/create.go b/internal/cmd/service-account/key/create/create.go
index 1e27768f6..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",
@@ -56,27 +58,25 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 ab7d6c367..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
}
@@ -115,6 +109,10 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceacco
}
func outputResult(p *print.Printer, key *serviceaccount.GetServiceAccountKeyResponse) error {
+ if key == nil {
+ return fmt.Errorf("key is nil")
+ }
+
marshaledKey, err := json.MarshalIndent(key, "", " ")
if err != nil {
return fmt.Errorf("marshal service account key: %w", err)
diff --git a/internal/cmd/service-account/key/describe/describe_test.go b/internal/cmd/service-account/key/describe/describe_test.go
index 8323ed679..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)
})
}
}
@@ -229,3 +185,36 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ key *serviceaccount.GetServiceAccountKeyResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty key",
+ args: args{
+ key: &serviceaccount.GetServiceAccountKeyResponse{},
+ },
+ 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.key); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 6c3c29da5..e450ef021 100644
--- a/internal/cmd/service-account/key/list/list.go
+++ b/internal/cmd/service-account/key/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -14,8 +15,7 @@ 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/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/serviceaccount"
)
@@ -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 service account keys",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 {
}
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 +78,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 +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{}
@@ -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 *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))
- 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 {
@@ -166,7 +141,12 @@ func outputResult(p *print.Printer, outputFormat string, keys []serviceaccount.S
if k.ValidUntil != nil {
validUntil = k.ValidUntil.String()
}
- table.AddRow(*k.Id, *k.Active, *k.CreatedAt, validUntil)
+ table.AddRow(
+ utils.PtrString(k.Id),
+ utils.PtrString(k.Active),
+ utils.PtrString(k.CreatedAt),
+ validUntil,
+ )
}
err := table.Display(p)
if err != nil {
@@ -174,5 +154,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 e6ec4b4e4..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)
})
}
}
@@ -196,3 +158,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ keys []serviceaccount.ServiceAccountKeyListResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty keys slice",
+ args: args{
+ keys: []serviceaccount.ServiceAccountKeyListResponse{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty key in keys slice",
+ args: args{
+ keys: []serviceaccount.ServiceAccountKeyListResponse{{}},
+ },
+ 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.keys); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/service-account/key/update/update.go b/internal/cmd/service-account/key/update/update.go
index 753756e94..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(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 7fc9ffbff..f444e83b3 100644
--- a/internal/cmd/service-account/list/list.go
+++ b/internal/cmd/service-account/list/list.go
@@ -2,10 +2,11 @@ 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-cli/internal/pkg/args"
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/examples"
@@ -15,8 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/service-account/client"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/serviceaccount"
)
@@ -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 service accounts",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +61,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 +75,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 +87,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 +106,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,31 +116,20 @@ 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))
- 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 {
account := serviceAccounts[i]
- table.AddRow(*account.Id, *account.Email)
+ table.AddRow(
+ utils.PtrString(account.Id),
+ utils.PtrString(account.Email),
+ )
}
err := table.Display(p)
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 92d89b8ce..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)
})
}
}
@@ -186,3 +148,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ serviceAccounts []serviceaccount.ServiceAccount
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty service accounts slice",
+ args: args{
+ serviceAccounts: []serviceaccount.ServiceAccount{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty service account in service accounts slice",
+ args: args{
+ serviceAccounts: []serviceaccount.ServiceAccount{{}},
+ },
+ 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.serviceAccounts); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 fdaf09b01..a3286219b 100644
--- a/internal/cmd/service-account/token/create/create.go
+++ b/internal/cmd/service-account/token/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/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"
@@ -13,8 +14,7 @@ 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/service-account/client"
-
- "github.com/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/serviceaccount"
)
@@ -32,7 +32,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",
@@ -52,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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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 +76,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("create access token: %w", err)
}
- return outputResult(p, model, token)
+ return outputResult(params.Printer, model.OutputFormat, model.ServiceAccountEmail, token)
},
}
@@ -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{}
@@ -122,15 +120,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
}
@@ -142,28 +132,15 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceacco
return req
}
-func outputResult(p *print.Printer, model *inputModel, token *serviceaccount.AccessToken) error {
- switch model.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))
- if err != nil {
- return fmt.Errorf("marshal service account access token: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat, serviceAccountEmail string, token *serviceaccount.AccessToken) error {
+ if token == nil {
+ return fmt.Errorf("token is nil")
+ }
+ 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
- default:
- p.Outputf("Created access token for service account %s. Token ID: %s\n\n", model.ServiceAccountEmail, *token.Id)
- p.Outputf("Valid until: %s\n", *token.ValidUntil)
- p.Outputf("Token: %s\n", *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 2cc8b55c9..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)
})
}
}
@@ -193,3 +158,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ serviceAccountEmail string
+ token *serviceaccount.AccessToken
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty token",
+ args: args{
+ token: &serviceaccount.AccessToken{},
+ },
+ 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.serviceAccountEmail, tt.args.token); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/service-account/token/list/list.go b/internal/cmd/service-account/token/list/list.go
index fc345d8cc..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"
@@ -14,6 +14,7 @@ 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/tables"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/serviceaccount"
@@ -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 access tokens of a service account",
@@ -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)
+ model, err := parseInput(params.Printer, cmd, args)
if err != 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,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
}
@@ -82,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)
},
}
@@ -98,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{}
@@ -126,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
}
@@ -144,29 +137,17 @@ 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))
- 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 {
t := tokensMetadata[i]
- table.AddRow(*t.Id, *t.Active, *t.CreatedAt, *t.ValidUntil)
+ table.AddRow(
+ utils.PtrString(t.Id),
+ utils.PtrString(t.Active),
+ utils.PtrString(t.CreatedAt),
+ utils.PtrString(t.ValidUntil),
+ )
}
err := table.Display(p)
if err != nil {
@@ -174,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 3ffed20f5..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)
})
}
}
@@ -196,3 +158,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ tokensMetadata []serviceaccount.AccessTokenMetadata
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty tokens metadata slice",
+ args: args{
+ tokensMetadata: []serviceaccount.AccessTokenMetadata{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty token metadata in tokens metadata slice",
+ args: args{
+ tokensMetadata: []serviceaccount.AccessTokenMetadata{{}},
+ },
+ 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.tokensMetadata); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 4a9324f3d..1602e3680 100644
--- a/internal/cmd/ske/cluster/create/create.go
+++ b/internal/cmd/ske/cluster/create/create.go
@@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
- "github.com/goccy/go-yaml"
+ "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"
@@ -13,11 +15,12 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
+ serviceEnablementClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/client"
+ serviceEnablementUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client"
skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
-
- "github.com/spf13/cobra"
+ "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"
)
@@ -34,10 +37,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 \"@\".",
@@ -46,13 +49,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`,
@@ -62,42 +65,48 @@ 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(params.Printer, params.CliVersion)
+ if err != nil {
+ return err
}
- // Check if SKE is enabled for this project
- enabled, err := skeUtils.ProjectEnabled(ctx, apiClient, model.ProjectId)
+ // Check if the project is enabled before trying to create
+ enabled, err := serviceEnablementUtils.ProjectEnabled(ctx, serviceEnablementApiClient, model.ProjectId, model.Region)
if err != nil {
return err
}
if !enabled {
- return fmt.Errorf("SKE isn't enabled for this project, please run 'stackit ske enable'")
+ return &errors.ServiceDisabledError{
+ Service: "ske",
+ }
}
// Check if cluster exists
- exists, err := skeUtils.ClusterExists(ctx, apiClient, model.ProjectId, model.ClusterName)
+ exists, err := skeUtils.ClusterExists(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName)
if err != nil {
return err
}
@@ -107,7 +116,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, model.Region)
if err != nil {
return fmt.Errorf("get default payload: %w", err)
}
@@ -124,16 +133,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 := spinner.New(params.Printer)
s.Start("Creating cluster")
- _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient, model.ProjectId, name).WaitWithContext(ctx)
+ _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient, model.ProjectId, model.Region, name).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for SKE cluster creation: %w", err)
}
s.Stop()
}
- return outputResult(p, model, projectLabel, resp)
+ return outputResult(params.Printer, model.OutputFormat, model.Async, projectLabel, resp)
},
}
configureFlags(cmd)
@@ -168,49 +177,28 @@ 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.CreateOrUpdateCluster(ctx, model.ProjectId, model.Region, model.ClusterName)
req = req.CreateOrUpdateClusterPayload(*model.Payload)
return req
}
-func outputResult(p *print.Printer, model *inputModel, projectLabel string, resp *ske.Cluster) error {
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- if err != nil {
- return fmt.Errorf("marshal SKE cluster: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal SKE cluster: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, projectLabel string, cluster *ske.Cluster) error {
+ if cluster == nil {
+ return fmt.Errorf("cluster is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, cluster, func() error {
operationState := "Created"
- if model.Async {
+ if async {
operationState = "Triggered creation of"
}
- p.Outputf("%s cluster for project %q. Cluster name: %s\n", operationState, projectLabel, *resp.Name)
+ 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 cc2c6bd58..99392a63d 100644
--- a/internal/cmd/ske/cluster/create/create_test.go
+++ b/internal/cmd/ske/cluster/create/create_test.go
@@ -2,7 +2,13 @@ package create
import (
"context"
+ "fmt"
"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"
@@ -23,6 +29,8 @@ var testClient = &ske.APIClient{}
var testProjectId = uuid.NewString()
var testClusterName = "cluster"
+const testRegion = "eu01"
+
var testPayload = &ske.CreateOrUpdateClusterPayload{
Kubernetes: &ske.Kubernetes{
Version: utils.Ptr("1.25.15"),
@@ -45,7 +53,7 @@ var testPayload = &ske.CreateOrUpdateClusterPayload{
Size: utils.Ptr(int64(40)),
},
AvailabilityZones: &[]string{"eu01-3"},
- Cri: &ske.CRI{Name: utils.Ptr("cri")},
+ Cri: &ske.CRI{Name: ske.CRINAME_DOCKER.Ptr()},
},
},
Extensions: &ske.Extension{
@@ -60,8 +68,8 @@ var testPayload = &ske.CreateOrUpdateClusterPayload{
MachineImageVersion: utils.Ptr(true),
},
TimeWindow: &ske.TimeWindow{
- End: utils.Ptr("0000-01-01T05:00:00+02:00"),
- Start: utils.Ptr("0000-01-01T03:00:00+02:00"),
+ 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))),
},
},
}
@@ -78,8 +86,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,
- payloadFlag: `{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ payloadFlag: fmt.Sprintf(`{
"name": "cli-jp",
"kubernetes": {
"version": "1.25.15"
@@ -98,7 +107,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": "%s" },
"availabilityZones": ["eu01-3"]
}
],
@@ -113,7 +122,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st
"start": "0000-01-01T03:00:00+02:00"
}
}
- }`,
+ }`, ske.CRINAME_DOCKER),
}
for _, mod := range mods {
mod(flagValues)
@@ -125,6 +134,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
ClusterName: testClusterName,
@@ -137,7 +147,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.CreateOrUpdateCluster(testCtx, testProjectId, testRegion, fixtureInputModel().ClusterName)
request = request.CreateOrUpdateClusterPayload(*testPayload)
for _, mod := range mods {
mod(&request)
@@ -226,62 +236,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)
})
}
}
@@ -314,3 +269,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ projectLabel string
+ cluster *ske.Cluster
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty cluster",
+ args: args{
+ cluster: &ske.Cluster{},
+ },
+ 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.cluster); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/ske/cluster/delete/delete.go b/internal/cmd/ske/cluster/delete/delete.go
index 657001abe..34de6f2c7 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"
@@ -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,9 +67,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Deleting cluster")
- _, err = wait.DeleteClusterWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx)
+ _, err = wait.DeleteClusterWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for SKE cluster deletion: %w", err)
}
@@ -80,7 +80,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
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.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..86cef5d06 100644
--- a/internal/cmd/ske/cluster/delete/delete_test.go
+++ b/internal/cmd/ske/cluster/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"
@@ -22,6 +22,8 @@ var testClient = &ske.APIClient{}
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.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)
})
}
}
diff --git a/internal/cmd/ske/cluster/describe/describe.go b/internal/cmd/ske/cluster/describe/describe.go
index 75a432bdb..3e94fa9d9 100644
--- a/internal/cmd/ske/cluster/describe/describe.go
+++ b/internal/cmd/ske/cluster/describe/describe.go
@@ -2,10 +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/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"
@@ -13,8 +15,7 @@ import (
"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/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/ske"
)
@@ -27,28 +28,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 +61,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,54 +80,41 @@ 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.GetCluster(ctx, model.ProjectId, model.Region, model.ClusterName)
return req
}
func outputResult(p *print.Printer, outputFormat string, cluster *ske.Cluster) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal SKE cluster: %w", err)
- }
- p.Outputln(string(details))
+ if cluster == nil {
+ return fmt.Errorf("cluster is nil")
+ }
- 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
}
table := tables.NewTable()
- table.AddRow("NAME", *cluster.Name)
- table.AddSeparator()
- table.AddRow("STATE", *cluster.Status.Aggregated)
- table.AddSeparator()
- table.AddRow("VERSION", *cluster.Kubernetes.Version)
+ table.AddRow("NAME", utils.PtrString(cluster.Name))
table.AddSeparator()
+ 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))
+ table.AddSeparator()
+ }
+
table.AddRow("ACL", acl)
err := table.Display(p)
if err != nil {
@@ -134,5 +122,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 335bb39c0..3049998fe 100644
--- a/internal/cmd/ske/cluster/describe/describe_test.go
+++ b/internal/cmd/ske/cluster/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"
@@ -22,6 +26,8 @@ var testClient = &ske.APIClient{}
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.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)
})
}
}
@@ -206,3 +167,215 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ cluster *ske.Cluster
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty cluster",
+ args: args{
+ cluster: &ske.Cluster{},
+ },
+ 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: utils.Ptr("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: utils.Ptr(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(&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 {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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 054fea2b3..5afa14cf8 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"
@@ -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",
@@ -56,20 +58,20 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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, model.Region)
if err != nil {
return err
}
@@ -89,7 +91,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
}
- return outputResult(p, model.FilePath, payload)
+ return outputResult(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)
clusterName := flags.FlagToStringPointer(p, cmd, clusterNameFlag)
@@ -116,24 +118,20 @@ 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.GetCluster(ctx, model.ProjectId, model.Region, *model.ClusterName)
return req
}
func outputResult(p *print.Printer, filePath *string, payload *ske.CreateOrUpdateClusterPayload) error {
+ if payload == nil {
+ return fmt.Errorf("payload is nil")
+ }
+
payloadBytes, err := json.MarshalIndent(*payload, "", " ")
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
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 3cf60e949..97f0aa013 100644
--- a/internal/cmd/ske/cluster/generate-payload/generate_payload_test.go
+++ b/internal/cmd/ske/cluster/generate-payload/generate_payload_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"
@@ -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.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)
})
}
}
@@ -208,3 +169,45 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ filePath *string
+ payload *ske.CreateOrUpdateClusterPayload
+ }
+ filePathDummy := "/dummy.txt"
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "missing payload",
+ args: args{
+ filePath: &filePathDummy,
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing file path",
+ args: args{
+ payload: &ske.CreateOrUpdateClusterPayload{},
+ },
+ 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.filePath, tt.args.payload); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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..5e679e38b
--- /dev/null
+++ b/internal/cmd/ske/cluster/hibernate/hibernate.go
@@ -0,0 +1,115 @@
+package hibernate
+
+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/projectname"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske/wait"
+)
+
+const (
+ clusterNameArg = "CLUSTER_NAME"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ClusterName string
+}
+
+func NewCmd(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 {
+ s := spinner.New(params.Printer)
+ s.Start("Hibernating cluster")
+ _, err = wait.TriggerClusterHibernationWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for SKE cluster hibernation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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.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..d9d531ef1
--- /dev/null
+++ b/internal/cmd/ske/cluster/hibernate/hibernate_test.go
@@ -0,0 +1,186 @@
+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"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske"
+)
+
+type testCtxKey struct{}
+
+const (
+ testRegion = "eu01"
+ testClusterName = "my-cluster"
+)
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &ske.APIClient{}
+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.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),
+ )
+ 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 d0ee6da45..b1fa9f024 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"
@@ -13,9 +13,11 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/projectname"
+ serviceEnablementClient "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/client"
+ serviceEnablementUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client"
- skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/utils"
"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/ske"
@@ -30,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,19 +51,25 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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(params.Printer, params.CliVersion)
if err != nil {
return err
}
// Check if SKE is enabled for this project
- enabled, err := skeUtils.ProjectEnabled(ctx, apiClient, model.ProjectId)
+ enabled, err := serviceEnablementUtils.ProjectEnabled(ctx, serviceEnablementApiClient, model.ProjectId, model.Region)
if err != nil {
return err
}
@@ -76,22 +84,21 @@ func NewCmd(p *print.Printer) *cobra.Command {
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
- }
// 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)
},
}
@@ -103,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{}
@@ -122,51 +129,48 @@ 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.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)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(clusters, yaml.IndentSequence(true))
- 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
- 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"
}
- table.AddRow(*c.Name, *c.Status.Aggregated, *c.Kubernetes.Version, len(*c.Nodepools), monitoring)
+ statusAggregated, kubernetesVersion := "", ""
+ if c.HasStatus() {
+ statusAggregated = utils.PtrString(c.Status.Aggregated)
+ }
+ if c.Kubernetes != nil {
+ kubernetesVersion = utils.PtrString(c.Kubernetes.Version)
+ }
+ countNodepools := 0
+ if c.Nodepools != nil {
+ countNodepools = len(*c.Nodepools)
+ }
+ table.AddRow(
+ utils.PtrString(c.Name),
+ statusAggregated,
+ kubernetesVersion,
+ countNodepools,
+ monitoring,
+ )
}
err := table.Display(p)
if err != nil {
@@ -174,5 +178,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 9e9a8c6a3..2b123dba0 100644
--- a/internal/cmd/ske/cluster/list/list_test.go
+++ b/internal/cmd/ske/cluster/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/ske"
)
@@ -23,10 +25,13 @@ var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &ske.APIClient{}
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.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)
})
}
}
@@ -186,3 +152,44 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ clusters []ske.Cluster
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "empty clusters slice",
+ args: args{
+ clusters: []ske.Cluster{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty cluster in clusters slice",
+ args: args{
+ clusters: []ske.Cluster{{}},
+ },
+ 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.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..0b5f406ca
--- /dev/null
+++ b/internal/cmd/ske/cluster/maintenance/maintenance.go
@@ -0,0 +1,115 @@
+package maintenance
+
+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/projectname"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske/wait"
+)
+
+const (
+ clusterNameArg = "CLUSTER_NAME"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ClusterName string
+}
+
+func NewCmd(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 {
+ s := spinner.New(params.Printer)
+ s.Start("Performing cluster maintenance")
+ _, err = wait.TriggerClusterMaintenanceWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for SKE cluster maintenance to complete: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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.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..fe0ab07cb
--- /dev/null
+++ b/internal/cmd/ske/cluster/maintenance/maintenance_test.go
@@ -0,0 +1,187 @@
+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"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske"
+)
+
+type testCtxKey struct{}
+
+const (
+ testRegion = "eu01"
+ testClusterName = "my-cluster"
+)
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &ske.APIClient{}
+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.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),
+ )
+ 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..0108ae568
--- /dev/null
+++ b/internal/cmd/ske/cluster/reconcile/reconcile.go
@@ -0,0 +1,103 @@
+package reconcile
+
+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/ske/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske/wait"
+)
+
+const (
+ clusterNameArg = "CLUSTER_NAME"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ClusterName string
+}
+
+func NewCmd(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 {
+ s := spinner.New(params.Printer)
+ s.Start("Performing cluster reconciliation")
+ _, err = wait.TriggerClusterReconciliationWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for SKE cluster reconciliation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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.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..5c96f295b
--- /dev/null
+++ b/internal/cmd/ske/cluster/reconcile/reconcile_test.go
@@ -0,0 +1,187 @@
+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"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske"
+)
+
+type testCtxKey struct{}
+
+const (
+ testRegion = "eu01"
+ testClusterName = "my-cluster"
+)
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &ske.APIClient{}
+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.ApiTriggerHibernateRequest {
+ request := testClient.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.ApiTriggerHibernateRequest
+ }{
+ {
+ 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),
+ )
+ 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 491daf86e..6150a9bda 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"
@@ -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, 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 := spinner.New(params.Printer)
s.Start("Updating cluster")
- _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient, model.ProjectId, name).WaitWithContext(ctx)
+ _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient, model.ProjectId, model.Region, name).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for SKE cluster update: %w", err)
}
s.Stop()
}
- return outputResult(p, model, resp)
+ return outputResult(params.Printer, model.OutputFormat, model.Async, model.ClusterName, resp)
},
}
configureFlags(cmd)
@@ -140,49 +139,28 @@ 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.CreateOrUpdateCluster(ctx, model.ProjectId, model.Region, model.ClusterName)
req = req.CreateOrUpdateClusterPayload(model.Payload)
return req
}
-func outputResult(p *print.Printer, model *inputModel, resp *ske.Cluster) error {
- switch model.OutputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(resp, "", " ")
- if err != nil {
- return fmt.Errorf("marshal SKE cluster: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal SKE cluster: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, async bool, clusterName string, cluster *ske.Cluster) error {
+ if cluster == nil {
+ return fmt.Errorf("cluster is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, cluster, func() error {
operationState := "Updated"
- if model.Async {
+ if async {
operationState = "Triggered update of"
}
- p.Info("%s cluster %q\n", operationState, model.ClusterName)
+ 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 4bcab1e72..e4a28fb91 100644
--- a/internal/cmd/ske/cluster/update/update_test.go
+++ b/internal/cmd/ske/cluster/update/update_test.go
@@ -2,10 +2,15 @@ package update
import (
"context"
+ "fmt"
"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"
@@ -23,6 +28,8 @@ var testClient = &ske.APIClient{}
var testProjectId = uuid.NewString()
var testClusterName = "cluster"
+const testRegion = "eu01"
+
var testPayload = ske.CreateOrUpdateClusterPayload{
Kubernetes: &ske.Kubernetes{
Version: utils.Ptr("1.25.15"),
@@ -45,7 +52,7 @@ var testPayload = ske.CreateOrUpdateClusterPayload{
Size: utils.Ptr(int64(40)),
},
AvailabilityZones: &[]string{"eu01-3"},
- Cri: &ske.CRI{Name: utils.Ptr("cri")},
+ Cri: &ske.CRI{Name: ske.CRINAME_DOCKER.Ptr()},
},
},
Extensions: &ske.Extension{
@@ -60,8 +67,8 @@ var testPayload = ske.CreateOrUpdateClusterPayload{
MachineImageVersion: utils.Ptr(true),
},
TimeWindow: &ske.TimeWindow{
- End: utils.Ptr("0000-01-01T05:00:00+02:00"),
- Start: utils.Ptr("0000-01-01T03:00:00+02:00"),
+ 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))),
},
},
}
@@ -78,8 +85,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,
- payloadFlag: `{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+ payloadFlag: fmt.Sprintf(`{
"name": "cli-jp",
"kubernetes": {
"version": "1.25.15"
@@ -98,7 +106,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": "%s" },
"availabilityZones": ["eu01-3"]
}
],
@@ -113,7 +121,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]st
"start": "0000-01-01T03:00:00+02:00"
}
}
- }`,
+ }`, ske.CRINAME_DOCKER),
}
for _, mod := range mods {
mod(flagValues)
@@ -125,6 +133,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
ClusterName: testClusterName,
@@ -137,7 +146,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.CreateOrUpdateCluster(testCtx, testProjectId, testRegion, fixtureInputModel().ClusterName)
request = request.CreateOrUpdateClusterPayload(testPayload)
for _, mod := range mods {
mod(&request)
@@ -214,54 +223,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)
})
}
}
@@ -294,3 +256,39 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ async bool
+ clusterName string
+ cluster *ske.Cluster
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty cluster",
+ args: args{
+ cluster: &ske.Cluster{},
+ },
+ 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.clusterName, tt.args.cluster); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, 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..64b0e5ccf
--- /dev/null
+++ b/internal/cmd/ske/cluster/wakeup/wakeup.go
@@ -0,0 +1,103 @@
+package wakeup
+
+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/ske/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/spinner"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske/wait"
+)
+
+const (
+ clusterNameArg = "CLUSTER_NAME"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ ClusterName string
+}
+
+func NewCmd(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 {
+ s := spinner.New(params.Printer)
+ s.Start("Performing cluster wakeup")
+ _, err = wait.TriggerClusterWakeupWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for SKE cluster wakeup: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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.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..dd93881c1
--- /dev/null
+++ b/internal/cmd/ske/cluster/wakeup/wakeup_test.go
@@ -0,0 +1,185 @@
+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"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/ske"
+)
+
+type testCtxKey struct{}
+
+const (
+ testRegion = "eu01"
+ testClusterName = "my-cluster"
+)
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &ske.APIClient{}
+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.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.ApiTriggerHibernateRequest
+ }{
+ {
+ 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),
+ )
+ 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..25f2601aa 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"
@@ -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,9 +84,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Completing credentials rotation")
- _, err = wait.CompleteCredentialsRotationWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx)
+ _, err = wait.CompleteCredentialsRotationWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for completing SKE credentials rotation %w", err)
}
@@ -97,8 +97,8 @@ func NewCmd(p *print.Printer) *cobra.Command {
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.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..ee40fc120 100644
--- a/internal/cmd/ske/credentials/complete-rotation/complete_rotation_test.go
+++ b/internal/cmd/ske/credentials/complete-rotation/complete_rotation_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"
@@ -22,6 +22,8 @@ var testClient = &ske.APIClient{}
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.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)
})
}
}
diff --git a/internal/cmd/ske/credentials/credentials.go b/internal/cmd/ske/credentials/credentials.go
index 9d421ad7d..13218d2fa 100644
--- a/internal/cmd/ske/credentials/credentials.go
+++ b/internal/cmd/ske/credentials/credentials.go
@@ -2,17 +2,15 @@ package credentials
import (
completerotation "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/complete-rotation"
- "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/describe"
- "github.com/stackitcloud/stackit-cli/internal/cmd/ske/credentials/rotate"
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",
@@ -20,13 +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(rotate.NewCmd(p))
- 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/describe/describe.go b/internal/cmd/ske/credentials/describe/describe.go
deleted file mode 100644
index 692bb9ace..000000000
--- a/internal/cmd/ske/credentials/describe/describe.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package describe
-
-import (
- "context"
- "encoding/json"
- "fmt"
-
- "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"
- "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"
- skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/utils"
- "github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/ske"
-)
-
-const (
- clusterNameArg = "CLUSTER_NAME"
-)
-
-type inputModel struct {
- *globalflags.GlobalFlagModel
- ClusterName string
-}
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: fmt.Sprintf("describe %s", clusterNameArg),
- Short: "Shows details of the credentials associated to a SKE cluster",
- Long: "Shows details of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster",
- Args: args.SingleArg(clusterNameArg, nil),
- Deprecated: fmt.Sprintf("%s\n%s\n%s\n%s\n",
- "and will be removed in a future release.",
- "Please use the following command to obtain a kubeconfig file instead:",
- " $ stackit ske kubeconfig create CLUSTER_NAME",
- "For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html",
- ),
- Example: examples.Build(
- examples.NewExample(
- `Get details of the credentials associated to the SKE cluster with name "my-cluster"`,
- "$ stackit ske credentials describe my-cluster"),
- examples.NewExample(
- `Get details of the credentials associated to the SKE cluster with name "my-cluster" in JSON format`,
- "$ stackit ske credentials describe my-cluster --output-format json"),
- ),
- 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)
- if err != nil {
- return err
- }
-
- // Check if SKE is enabled for this project
- enabled, err := skeUtils.ProjectEnabled(ctx, apiClient, model.ProjectId)
- if err != nil {
- return err
- }
- if !enabled {
- return fmt.Errorf("SKE isn't enabled for this project, please run 'stackit ske enable'")
- }
-
- // Call API
- req := buildRequest(ctx, model, apiClient)
- resp, err := req.Execute()
- if err != nil {
- return fmt.Errorf("get SKE credentials: %w", err)
- }
-
- return outputResult(p, model.OutputFormat, resp)
- },
- }
- 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,
- }
-
- if p.IsVerbosityDebug() {
- modelStr, err := print.BuildDebugStrFromInputModel(model)
- if 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
-}
-
-func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiGetCredentialsRequest {
- req := apiClient.GetCredentials(ctx, model.ProjectId, model.ClusterName)
- return req
-}
-
-func outputResult(p *print.Printer, outputFormat string, credentials *ske.Credentials) error {
- switch outputFormat {
- case print.JSONOutputFormat:
- details, err := json.MarshalIndent(credentials, "", " ")
- if err != nil {
- return fmt.Errorf("marshal SKE credentials: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- case print.YAMLOutputFormat:
- details, err := yaml.MarshalWithOptions(credentials, yaml.IndentSequence(true))
- if err != nil {
- return fmt.Errorf("marshal SKE credentials: %w", err)
- }
- p.Outputln(string(details))
-
- return nil
- default:
- table := tables.NewTable()
- table.AddRow("SERVER", *credentials.Server)
- table.AddSeparator()
- table.AddRow("TOKEN", *credentials.Token)
- err := table.Display(p)
- if err != nil {
- return fmt.Errorf("render table: %w", err)
- }
-
- return nil
- }
-}
diff --git a/internal/cmd/ske/credentials/rotate/rotate.go b/internal/cmd/ske/credentials/rotate/rotate.go
deleted file mode 100644
index 5b1969a0a..000000000
--- a/internal/cmd/ske/credentials/rotate/rotate.go
+++ /dev/null
@@ -1,125 +0,0 @@
-package rotate
-
-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/ske/client"
- "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"
-)
-
-const (
- clusterNameArg = "CLUSTER_NAME"
-)
-
-type inputModel struct {
- *globalflags.GlobalFlagModel
- ClusterName string
-}
-
-func NewCmd(p *print.Printer) *cobra.Command {
- cmd := &cobra.Command{
- Use: fmt.Sprintf("rotate %s", clusterNameArg),
- Short: "Rotates credentials associated to a SKE cluster",
- Long: "Rotates credentials associated to a STACKIT Kubernetes Engine (SKE) cluster. The old credentials will be invalid after the operation.",
- Args: args.SingleArg(clusterNameArg, nil),
- Deprecated: fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n",
- "and will be removed in a future release.",
- "Please use the 2-step credential rotation flow instead, by running the commands:",
- " $ stackit ske credentials start-rotation CLUSTER_NAME",
- " $ stackit ske credentials complete-rotation CLUSTER_NAME",
- "For more information, visit: https://docs.stackit.cloud/stackit/en/how-to-rotate-ske-credentials-200016334.html",
- ),
- Example: examples.Build(
- examples.NewExample(
- `Rotate credentials associated to the SKE cluster with name "my-cluster"`,
- "$ stackit ske credentials rotate my-cluster"),
- ),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx := context.Background()
- model, err := parseInput(p, cmd, args)
- if err != nil {
- return err
- }
-
- // Configure API client
- apiClient, err := client.ConfigureClient(p)
- if err != nil {
- return err
- }
-
- if !model.AssumeYes {
- prompt := fmt.Sprintf("Are you sure you want to rotate credentials for SKE cluster %q? (The old credentials will be invalid after this operation)", model.ClusterName)
- err = p.PromptForConfirmation(prompt)
- if err != nil {
- return err
- }
- }
-
- // Call API
- req := buildRequest(ctx, model, apiClient)
- _, err = req.Execute()
- if err != nil {
- return fmt.Errorf("rotate SKE credentials: %w", err)
- }
-
- // Wait for async operation, if async mode not enabled
- if !model.Async {
- s := spinner.New(p)
- s.Start("Rotating credentials")
- _, err = wait.RotateCredentialsWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx)
- if err != nil {
- return fmt.Errorf("wait for SKE credentials rotation: %w", err)
- }
- s.Stop()
- }
-
- operationState := "Rotated"
- if model.Async {
- operationState = "Triggered rotation of"
- }
- p.Info("%s credentials for 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,
- }
-
- if p.IsVerbosityDebug() {
- modelStr, err := print.BuildDebugStrFromInputModel(model)
- if 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
-}
-
-func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerRotateCredentialsRequest {
- req := apiClient.TriggerRotateCredentials(ctx, model.ProjectId, model.ClusterName)
- return req
-}
diff --git a/internal/cmd/ske/credentials/start-rotation/start_rotation.go b/internal/cmd/ske/credentials/start-rotation/start_rotation.go
index b11c42623..3cb3cee55 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"
@@ -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,9 +87,9 @@ func NewCmd(p *print.Printer) *cobra.Command {
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Starting credentials rotation")
- _, err = wait.StartCredentialsRotationWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx)
+ _, err = wait.StartCredentialsRotationWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.ClusterName).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for start SKE credentials rotation %w", err)
}
@@ -100,8 +100,8 @@ func NewCmd(p *print.Printer) *cobra.Command {
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.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..063269174 100644
--- a/internal/cmd/ske/credentials/start-rotation/start_rotation_test.go
+++ b/internal/cmd/ske/credentials/start-rotation/start_rotation_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"
@@ -22,6 +22,8 @@ var testClient = &ske.APIClient{}
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.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)
})
}
}
diff --git a/internal/cmd/ske/describe/describe.go b/internal/cmd/ske/describe/describe.go
index 433a60cd3..414525335 100644
--- a/internal/cmd/ske/describe/describe.go
+++ b/internal/cmd/ske/describe/describe.go
@@ -2,27 +2,28 @@ 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-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/services/service-enablement/client"
+ skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils"
"github.com/stackitcloud/stackit-cli/internal/pkg/tables"
-
- "github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/ske"
+ "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",
@@ -35,12 +36,12 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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 +53,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
return fmt.Errorf("read SKE project details: %w", err)
}
- return outputResult(p, model.OutputFormat, resp)
+ 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{}
@@ -68,51 +69,32 @@ 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 *ske.APIClient) ske.ApiGetServiceStatusRequest {
- req := apiClient.GetServiceStatus(ctx, model.ProjectId)
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceenablement.APIClient) serviceenablement.ApiGetServiceStatusRegionalRequest {
+ req := apiClient.GetServiceStatusRegional(ctx, model.Region, model.ProjectId, skeUtils.SKEServiceId)
return req
}
-func outputResult(p *print.Printer, outputFormat string, project *ske.ProjectResponse) error {
- 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))
- if err != nil {
- return fmt.Errorf("marshal SKE project details: %w", err)
- }
- p.Outputln(string(details))
+func outputResult(p *print.Printer, outputFormat string, project *serviceenablement.ServiceStatus, projectId string) error {
+ if project == nil {
+ return fmt.Errorf("project is nil")
+ }
- return nil
- default:
+ return p.OutputResult(outputFormat, project, func() error {
table := tables.NewTable()
- table.AddRow("ID", *project.ProjectId)
+ table.AddRow("ID", projectId)
table.AddSeparator()
- table.AddRow("STATE", *project.State)
+ if project.HasState() {
+ table.AddRow("STATE", utils.PtrString(project.State))
+ }
err := table.Display(p)
if err != nil {
return fmt.Errorf("render table: %w", err)
}
return nil
- }
+ })
}
diff --git a/internal/cmd/ske/describe/describe_test.go b/internal/cmd/ske/describe/describe_test.go
index aa2efd992..53dd3afc8 100644
--- a/internal/cmd/ske/describe/describe_test.go
+++ b/internal/cmd/ske/describe/describe_test.go
@@ -4,27 +4,30 @@ 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"
+ serviceEnablementUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
- "github.com/spf13/cobra"
- "github.com/stackitcloud/stackit-sdk-go/services/ske"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &ske.APIClient{}
+var testClient = &serviceenablement.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,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -36,6 +39,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
}
@@ -45,8 +49,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *ske.ApiGetServiceStatusRequest)) ske.ApiGetServiceStatusRequest {
- request := testClient.GetServiceStatus(testCtx, testProjectId)
+func fixtureRequest(mods ...func(request *serviceenablement.ApiGetServiceStatusRegionalRequest)) serviceenablement.ApiGetServiceStatusRegionalRequest {
+ request := testClient.GetServiceStatusRegional(testCtx, testRegion, testProjectId, serviceEnablementUtils.SKEServiceId) //nolint:staticcheck //command will be removed in a later update
for _, mod := range mods {
mod(&request)
}
@@ -56,6 +60,7 @@ func fixtureRequest(mods ...func(request *ske.ApiGetServiceStatusRequest)) ske.A
func TestParseInput(t *testing.T) {
tests := []struct {
description string
+ argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
@@ -74,21 +79,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,
},
@@ -96,46 +101,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)
})
}
}
@@ -145,7 +111,7 @@ func TestBuildRequest(t *testing.T) {
description string
model *inputModel
isValid bool
- expectedRequest ske.ApiGetServiceStatusRequest
+ expectedRequest serviceenablement.ApiGetServiceStatusRegionalRequest
}{
{
description: "base",
@@ -168,3 +134,38 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+func TestOutputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ project *serviceenablement.ServiceStatus
+ projectId string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty project",
+ args: args{
+ project: &serviceenablement.ServiceStatus{},
+ },
+ 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.project, tt.args.projectId); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/ske/disable/disable.go b/internal/cmd/ske/disable/disable.go
index 94d15d9a7..075711b1a 100644
--- a/internal/cmd/ske/disable/disable.go
+++ b/internal/cmd/ske/disable/disable.go
@@ -4,25 +4,28 @@ 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/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/services/service-enablement/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils"
"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"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/wait"
)
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",
@@ -35,43 +38,41 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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
req := buildRequest(ctx, model, apiClient)
- _, err = req.Execute()
+ err = req.Execute()
if err != nil {
return fmt.Errorf("disable SKE: %w", err)
}
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Disabling SKE")
- _, err = wait.DisableServiceWaitHandler(ctx, apiClient, model.ProjectId).WaitWithContext(ctx)
+ _, err = wait.DisableServiceWaitHandler(ctx, apiClient, model.Region, model.ProjectId, utils.SKEServiceId).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for SKE disabling: %w", err)
}
@@ -82,14 +83,14 @@ func NewCmd(p *print.Printer) *cobra.Command {
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{}
@@ -99,19 +100,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 *ske.APIClient) ske.ApiDisableServiceRequest {
- req := apiClient.DisableService(ctx, model.ProjectId)
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceenablement.APIClient) serviceenablement.ApiDisableServiceRegionalRequest {
+ req := apiClient.DisableServiceRegional(ctx, model.Region, model.ProjectId, utils.SKEServiceId)
return req
}
diff --git a/internal/cmd/ske/disable/disable_test.go b/internal/cmd/ske/disable/disable_test.go
index 01f253e99..978e383ed 100644
--- a/internal/cmd/ske/disable/disable_test.go
+++ b/internal/cmd/ske/disable/disable_test.go
@@ -5,26 +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/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/ske"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &ske.APIClient{}
+var testClient = &serviceenablement.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,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -36,6 +36,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
}
@@ -45,8 +46,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *ske.ApiDisableServiceRequest)) ske.ApiDisableServiceRequest {
- request := testClient.DisableService(testCtx, testProjectId)
+func fixtureRequest(mods ...func(request *serviceenablement.ApiDisableServiceRegionalRequest)) serviceenablement.ApiDisableServiceRegionalRequest {
+ request := testClient.DisableServiceRegional(testCtx, testRegion, testProjectId, utils.SKEServiceId)
for _, mod := range mods {
mod(&request)
}
@@ -56,6 +57,7 @@ func fixtureRequest(mods ...func(request *ske.ApiDisableServiceRequest)) ske.Api
func TestParseInput(t *testing.T) {
tests := []struct {
description string
+ argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
@@ -74,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,
},
@@ -96,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)
})
}
}
@@ -144,7 +107,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest ske.ApiDisableServiceRequest
+ expectedRequest serviceenablement.ApiDisableServiceRegionalRequest
}{
{
description: "base",
diff --git a/internal/cmd/ske/enable/enable.go b/internal/cmd/ske/enable/enable.go
index 0a9717ff4..366f8d5f2 100644
--- a/internal/cmd/ske/enable/enable.go
+++ b/internal/cmd/ske/enable/enable.go
@@ -4,25 +4,28 @@ 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/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/services/service-enablement/client"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/services/service-enablement/utils"
"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"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/wait"
)
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",
@@ -35,43 +38,41 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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
req := buildRequest(ctx, model, apiClient)
- _, err = req.Execute()
+ err = req.Execute()
if err != nil {
return fmt.Errorf("enable SKE: %w", err)
}
// Wait for async operation, if async mode not enabled
if !model.Async {
- s := spinner.New(p)
+ s := spinner.New(params.Printer)
s.Start("Enabling SKE")
- _, err = wait.EnableServiceWaitHandler(ctx, apiClient, model.ProjectId).WaitWithContext(ctx)
+ _, err = wait.EnableServiceWaitHandler(ctx, apiClient, model.Region, model.ProjectId, utils.SKEServiceId).WaitWithContext(ctx)
if err != nil {
return fmt.Errorf("wait for SKE enabling: %w", err)
}
@@ -82,14 +83,14 @@ func NewCmd(p *print.Printer) *cobra.Command {
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{}
@@ -99,19 +100,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 *ske.APIClient) ske.ApiEnableServiceRequest {
- req := apiClient.EnableService(ctx, model.ProjectId)
+func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceenablement.APIClient) serviceenablement.ApiEnableServiceRegionalRequest {
+ req := apiClient.EnableServiceRegional(ctx, model.Region, model.ProjectId, utils.SKEServiceId)
return req
}
diff --git a/internal/cmd/ske/enable/enable_test.go b/internal/cmd/ske/enable/enable_test.go
index faba53553..add7b850b 100644
--- a/internal/cmd/ske/enable/enable_test.go
+++ b/internal/cmd/ske/enable/enable_test.go
@@ -5,26 +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/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/ske"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement"
)
-var projectIdFlag = globalflags.ProjectIdFlag
-
type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
-var testClient = &ske.APIClient{}
+var testClient = &serviceenablement.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,
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
}
for _, mod := range mods {
mod(flagValues)
@@ -36,6 +36,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
}
@@ -45,8 +46,8 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
return model
}
-func fixtureRequest(mods ...func(request *ske.ApiEnableServiceRequest)) ske.ApiEnableServiceRequest {
- request := testClient.EnableService(testCtx, testProjectId)
+func fixtureRequest(mods ...func(request *serviceenablement.ApiEnableServiceRegionalRequest)) serviceenablement.ApiEnableServiceRegionalRequest {
+ request := testClient.EnableServiceRegional(testCtx, testRegion, testProjectId, utils.SKEServiceId)
for _, mod := range mods {
mod(&request)
}
@@ -56,6 +57,7 @@ func fixtureRequest(mods ...func(request *ske.ApiEnableServiceRequest)) ske.ApiE
func TestParseInput(t *testing.T) {
tests := []struct {
description string
+ argValues []string
flagValues map[string]string
isValid bool
expectedModel *inputModel
@@ -74,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,
},
@@ -96,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)
})
}
}
@@ -144,7 +107,7 @@ func TestBuildRequest(t *testing.T) {
tests := []struct {
description string
model *inputModel
- expectedRequest ske.ApiEnableServiceRequest
+ expectedRequest serviceenablement.ApiEnableServiceRegionalRequest
}{
{
description: "base",
diff --git a/internal/cmd/ske/kubeconfig/create/create.go b/internal/cmd/ske/kubeconfig/create/create.go
index 53310c43f..355364098 100644
--- a/internal/cmd/ske/kubeconfig/create/create.go
+++ b/internal/cmd/ske/kubeconfig/create/create.go
@@ -5,6 +5,8 @@ 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"
@@ -14,6 +16,7 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
"github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/client"
skeUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/ske/utils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/spf13/cobra"
"github.com/stackitcloud/stackit-sdk-go/services/ske"
@@ -22,64 +25,79 @@ import (
const (
clusterNameArg = "CLUSTER_NAME"
- loginFlag = "login"
- expirationFlag = "expiration"
- filepathFlag = "filepath"
+ disableWritingFlag = "disable-writing"
+ expirationFlag = "expiration"
+ filepathFlag = "filepath"
+ loginFlag = "login"
+ overwriteFlag = "overwrite"
)
type inputModel struct {
*globalflags.GlobalFlagModel
ClusterName string
- Filepath *string
+ DisableWriting bool
ExpirationTime *string
+ Filepath *string
Login 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 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.",
- "By default the kubeconfig is created in the .kube folder, in the user's home directory. The kubeconfig file will be overwritten if it already exists.",
- "You can override this behavior by specifying a custom filepath with the --filepath 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 1h.",
+ "Creates a kubeconfig for a STACKIT Kubernetes Engine (SKE) cluster, 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 a kubeconfig for the SKE cluster with name "my-cluster"`,
+ `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.",
"$ 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`,
+ `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.`,
"$ stackit ske kubeconfig create my-cluster --expiration 30d"),
examples.NewExample(
- `Create a kubeconfig for the SKE cluster with name "my-cluster" and set the expiration time to 2 months`,
+ `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.`,
"$ stackit ske kubeconfig create my-cluster --expiration 2M"),
examples.NewExample(
- `Create a kubeconfig for the SKE cluster with name "my-cluster" in a custom filepath`,
+ `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`,
+ "$ 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."`,
+ "$ 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 {
- 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)
- err = p.PromptForConfirmation(prompt)
+ 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)
+ }
+ err = params.Printer.PromptForConfirmation(prompt)
if err != nil {
return err
}
@@ -131,14 +149,19 @@ func NewCmd(p *print.Printer) *cobra.Command {
kubeconfigPath = *model.Filepath
}
- if model.OutputFormat != print.JSONOutputFormat {
- err = skeUtils.WriteConfigFile(kubeconfigPath, kubeconfig)
+ if !model.DisableWriting {
+ if model.Overwrite {
+ err = skeUtils.WriteConfigFile(kubeconfigPath, kubeconfig)
+ } else {
+ err = skeUtils.MergeKubeConfig(kubeconfigPath, kubeconfig)
+ }
if err != nil {
return fmt.Errorf("write kubeconfig file: %w", err)
}
+ params.Printer.Outputf("\nSet kubectl context to %s with: kubectl config use-context %s\n", model.ClusterName, model.ClusterName)
}
- return outputResult(p, model, kubeconfigPath, respKubeconfig, respLogin)
+ return outputResult(params.Printer, model.OutputFormat, model.ClusterName, kubeconfigPath, respKubeconfig, respLogin)
},
}
configureFlags(cmd)
@@ -146,10 +169,11 @@ 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. 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().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().Bool(overwriteFlag, false, "Overwrite the kubeconfig file.")
cmd.MarkFlagsMutuallyExclusive(loginFlag, expirationFlag)
}
@@ -174,28 +198,30 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu
}
}
+ disableWriting := flags.FlagToBoolValue(p, cmd, disableWritingFlag)
+
+ isInvalidOutputFormat := globalFlags.OutputFormat == "" || globalFlags.OutputFormat == print.NoneOutputFormat || globalFlags.OutputFormat == print.PrettyOutputFormat
+ if disableWriting && isInvalidOutputFormat {
+ return nil, fmt.Errorf("when setting the flag --%s, you must specify --%s as one of the values: %s",
+ disableWritingFlag, globalflags.OutputFormatFlag, fmt.Sprintf("%s, %s", print.JSONOutputFormat, print.YAMLOutputFormat))
+ }
+
model := inputModel{
- GlobalFlagModel: globalFlags,
ClusterName: clusterName,
- Filepath: flags.FlagToStringPointer(p, cmd, filepathFlag),
+ DisableWriting: disableWriting,
ExpirationTime: expTime,
+ Filepath: flags.FlagToStringPointer(p, cmd, filepathFlag),
+ GlobalFlagModel: globalFlags,
Login: flags.FlagToBoolValue(p, cmd, loginFlag),
+ 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.CreateKubeconfig(ctx, model.ProjectId, model.Region, model.ClusterName)
payload := ske.CreateKubeconfigPayload{}
@@ -207,11 +233,11 @@ func buildRequestCreate(ctx context.Context, model *inputModel, apiClient *ske.A
}
func buildRequestLogin(ctx context.Context, model *inputModel, apiClient *ske.APIClient) (ske.ApiGetLoginKubeconfigRequest, error) {
- return apiClient.GetLoginKubeconfig(ctx, model.ProjectId, model.ClusterName), nil
+ return apiClient.GetLoginKubeconfig(ctx, model.ProjectId, model.Region, model.ClusterName), nil
}
-func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.LoginKubeconfig) error {
- switch model.OutputFormat {
+func outputResult(p *print.Printer, outputFormat, clusterName, kubeconfigPath string, respKubeconfig *ske.Kubeconfig, respLogin *ske.LoginKubeconfig) error {
+ switch outputFormat {
case print.JSONOutputFormat:
var err error
var details []byte
@@ -230,9 +256,9 @@ func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, re
var err error
var details []byte
if respKubeconfig != nil {
- details, err = yaml.MarshalWithOptions(respKubeconfig, yaml.IndentSequence(true))
+ details, err = yaml.MarshalWithOptions(respKubeconfig, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
} else if respLogin != nil {
- details, err = yaml.MarshalWithOptions(respLogin, yaml.IndentSequence(true))
+ details, err = yaml.MarshalWithOptions(respLogin, yaml.IndentSequence(true), yaml.UseJSONMarshaler())
}
if err != nil {
return fmt.Errorf("marshal SKE Kubeconfig: %w", err)
@@ -243,9 +269,9 @@ func outputResult(p *print.Printer, model *inputModel, kubeconfigPath string, re
default:
var expiration string
if respKubeconfig != nil {
- expiration = fmt.Sprintf(", with expiration date %v (UTC)", *respKubeconfig.ExpirationTimestamp)
+ expiration = fmt.Sprintf(", with expiration date %v (UTC)", utils.ConvertTimePToDateTimeString(respKubeconfig.ExpirationTimestamp))
}
- p.Outputf("Created kubeconfig file for cluster %s in %q%s\n", model.ClusterName, kubeconfigPath, expiration)
+ p.Outputf("Updated kubeconfig file for cluster %s in %q%s\n", clusterName, kubeconfigPath, expiration)
return nil
}
diff --git a/internal/cmd/ske/kubeconfig/create/create_test.go b/internal/cmd/ske/kubeconfig/create/create_test.go
index 86fb29ac4..f8e826064 100644
--- a/internal/cmd/ske/kubeconfig/create/create_test.go
+++ b/internal/cmd/ske/kubeconfig/create/create_test.go
@@ -4,13 +4,15 @@ 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-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"
)
@@ -23,6 +25,8 @@ var testClient = &ske.APIClient{}
var testProjectId = uuid.NewString()
var testClusterName = "cluster"
+const testRegion = "eu01"
+
func fixtureArgValues(mods ...func(argValues []string)) []string {
argValues := []string{
testClusterName,
@@ -35,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 +52,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel {
model := &inputModel{
GlobalFlagModel: &globalflags.GlobalFlagModel{
ProjectId: testProjectId,
+ Region: testRegion,
Verbosity: globalflags.VerbosityDefault,
},
ClusterName: testClusterName,
@@ -58,7 +64,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.CreateKubeconfig(testCtx, testProjectId, testRegion, testClusterName)
request = request.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{})
for _, mod := range mods {
mod(&request)
@@ -156,58 +162,54 @@ func TestParseInput(t *testing.T) {
}),
isValid: false,
},
+ {
+ description: "disable writing and invalid output format",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[disableWritingFlag] = "true"
+ }),
+ isValid: false,
+ },
+ {
+ description: "disable writing and valid output format",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[disableWritingFlag] = "true"
+ flagValues[globalflags.OutputFormatFlag] = print.YAMLOutputFormat
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.DisableWriting = true
+ model.OutputFormat = print.YAMLOutputFormat
+ }),
+ isValid: true,
+ },
+ {
+ description: "enable overwrite",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[overwriteFlag] = "true"
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Overwrite = true
+ }),
+ isValid: true,
+ },
+ {
+ description: "disable overwrite",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[overwriteFlag] = "false"
+ }),
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Overwrite = false
+ }),
+ 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 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,3 +249,47 @@ func TestBuildRequestCreate(t *testing.T) {
})
}
}
+
+func Test_outputResult(t *testing.T) {
+ type args struct {
+ outputFormat string
+ clusterName string
+ kubeconfigPath string
+ respKubeconfig *ske.Kubeconfig
+ respLogin *ske.LoginKubeconfig
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "missing kubeconfig",
+ args: args{
+ respLogin: &ske.LoginKubeconfig{},
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing login",
+ args: args{
+ respKubeconfig: &ske.Kubeconfig{},
+ },
+ 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.clusterName, tt.args.kubeconfigPath, tt.args.respKubeconfig, tt.args.respLogin); (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 568b34c98..711ad56bd 100644
--- a/internal/cmd/ske/kubeconfig/login/login.go
+++ b/internal/cmd/ske/kubeconfig/login/login.go
@@ -12,10 +12,14 @@ import (
"strconv"
"time"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/types"
+
"github.com/stackitcloud/stackit-cli/internal/pkg/cache"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags"
"k8s.io/client-go/rest"
"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/stackitcloud/stackit-cli/internal/pkg/services/ske/client"
@@ -34,7 +38,7 @@ const (
refreshBeforeDuration = 15 * time.Minute // 15 min
)
-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",
@@ -54,7 +58,7 @@ func NewCmd(p *print.Printer) *cobra.Command {
"$ kubectl cluster-info",
"$ kubectl get pods"),
),
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(cmd *cobra.Command, _ []string) error {
ctx := context.Background()
if err := cache.Init(); err != nil {
@@ -68,13 +72,13 @@ func NewCmd(p *print.Printer) *cobra.Command {
"See `stackit ske kubeconfig login --help` for detailed usage instructions.")
}
- clusterConfig, err := parseClusterConfig()
+ clusterConfig, err := parseClusterConfig(params.Printer, cmd)
if err != nil {
return fmt.Errorf("parseClusterConfig: %w", err)
}
// Configure API client
- apiClient, err := client.ConfigureClient(p)
+ apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion)
if err != nil {
return err
}
@@ -82,33 +86,33 @@ func NewCmd(p *print.Printer) *cobra.Command {
cachedKubeconfig := getCachedKubeConfig(clusterConfig.cacheKey)
if cachedKubeconfig == nil {
- return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil)
+ return GetAndOutputKubeconfig(ctx, params.Printer, apiClient, clusterConfig, false, nil)
}
certPem, _ := pem.Decode(cachedKubeconfig.CertData)
if certPem == nil {
_ = cache.DeleteObject(clusterConfig.cacheKey)
- return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil)
+ return GetAndOutputKubeconfig(ctx, params.Printer, apiClient, clusterConfig, false, nil)
}
certificate, err := x509.ParseCertificate(certPem.Bytes)
if err != nil {
_ = cache.DeleteObject(clusterConfig.cacheKey)
- return GetAndOutputKubeconfig(ctx, p, apiClient, clusterConfig, false, nil)
+ return GetAndOutputKubeconfig(ctx, params.Printer, 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)
+ return GetAndOutputKubeconfig(ctx, params.Printer, 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)
+ return GetAndOutputKubeconfig(ctx, params.Printer, 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 {
+ if err := output(params.Printer, clusterConfig.cacheKey, cachedKubeconfig); err != nil {
return err
}
return nil
@@ -118,13 +122,14 @@ func NewCmd(p *print.Printer) *cobra.Command {
}
type clusterConfig struct {
- STACKITProjectID string `json:"stackitProjectId"`
+ STACKITProjectID string `json:"stackitProjectID"`
ClusterName string `json:"clusterName"`
+ Region string `json:"region"`
cacheKey string
}
-func parseClusterConfig() (*clusterConfig, error) {
+func parseClusterConfig(p *print.Printer, cmd *cobra.Command) (*clusterConfig, error) {
obj, _, err := exec.LoadExecCredentialFromEnv()
if err != nil {
return nil, fmt.Errorf("LoadExecCredentialFromEnv: %w", err)
@@ -146,15 +151,25 @@ 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)
+ }
+
+ clusterConfig.cacheKey = fmt.Sprintf("ske-login-%x", sha256.Sum256([]byte(execCredential.Spec.Cluster.Server+"\x00"+authEmail)))
+
+ // 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 config, nil
+ return clusterConfig, nil
}
func getCachedKubeConfig(key string) *rest.Config {
@@ -199,7 +214,7 @@ func GetAndOutputKubeconfig(ctx context.Context, p *print.Printer, apiClient *sk
}
func buildRequest(ctx context.Context, apiClient *ske.APIClient, clusterConfig *clusterConfig) ske.ApiCreateKubeconfigRequest {
- req := apiClient.CreateKubeconfig(ctx, clusterConfig.STACKITProjectID, clusterConfig.ClusterName)
+ req := apiClient.CreateKubeconfig(ctx, clusterConfig.STACKITProjectID, clusterConfig.Region, clusterConfig.ClusterName)
expirationSeconds := strconv.Itoa(expirationSeconds)
return req.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{ExpirationSeconds: &expirationSeconds})
@@ -223,7 +238,7 @@ func output(p *print.Printer, cacheKey string, kubeconfig *rest.Config) error {
return fmt.Errorf("marshal ExecCredential: %w", err)
}
- p.Outputf(string(output))
+ p.Outputf("%s", string(output))
return nil
}
diff --git a/internal/cmd/ske/kubeconfig/login/login_test.go b/internal/cmd/ske/kubeconfig/login/login_test.go
index c6b94c9a9..ce22fbc1f 100644
--- a/internal/cmd/ske/kubeconfig/login/login_test.go
+++ b/internal/cmd/ske/kubeconfig/login/login_test.go
@@ -22,11 +22,14 @@ var testClient = &ske.APIClient{}
var testProjectId = uuid.NewString()
var testClusterName = "cluster"
+const testRegion = "eu01"
+
func fixtureClusterConfig(mods ...func(clusterConfig *clusterConfig)) *clusterConfig {
clusterConfig := &clusterConfig{
STACKITProjectID: testProjectId,
ClusterName: testClusterName,
cacheKey: "",
+ Region: testRegion,
}
for _, mod := range mods {
mod(clusterConfig)
@@ -35,7 +38,7 @@ func fixtureClusterConfig(mods ...func(clusterConfig *clusterConfig)) *clusterCo
}
func fixtureRequest(mods ...func(request *ske.ApiCreateKubeconfigRequest)) ske.ApiCreateKubeconfigRequest {
- request := testClient.CreateKubeconfig(testCtx, testProjectId, testClusterName)
+ request := testClient.CreateKubeconfig(testCtx, testProjectId, testRegion, testClusterName)
request = request.CreateKubeconfigPayload(ske.CreateKubeconfigPayload{})
for _, mod := range mods {
mod(&request)
diff --git a/internal/cmd/ske/options/options.go b/internal/cmd/ske/options/options.go
index e69c93ece..21f04d028 100644
--- a/internal/cmd/ske/options/options.go
+++ b/internal/cmd/ske/options/options.go
@@ -5,8 +5,11 @@ import (
"encoding/json"
"fmt"
"strings"
+ "time"
- "github.com/goccy/go-yaml"
+ "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/examples"
"github.com/stackitcloud/stackit-cli/internal/pkg/flags"
@@ -14,8 +17,7 @@ import (
"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/spf13/cobra"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/ske"
)
@@ -36,7 +38,7 @@ 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",
@@ -58,25 +60,25 @@ func NewCmd(p *print.Printer) *cobra.Command {
),
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, 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)
@@ -91,7 +93,7 @@ func configureFlags(cmd *cobra.Command) {
cmd.Flags().Bool(volumeTypesFlag, false, "Lists supported volume types")
}
-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)
@@ -117,24 +119,22 @@ 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.ListProviderOptions(ctx, model.Region)
return req
}
func outputResult(p *print.Printer, model *inputModel, options *ske.ProviderOptions) error {
+ if model == nil || model.GlobalFlagModel == nil {
+ return fmt.Errorf("model is nil")
+ } else if options == nil {
+ return fmt.Errorf("options is nil")
+ }
+
// filter output based on the flags
if !model.AvailabilityZones {
options.AvailabilityZones = nil
@@ -156,42 +156,42 @@ 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))
- 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 {
- content := ""
- content += renderAvailabilityZones(options)
+ if options == nil {
+ return fmt.Errorf("options is nil")
+ }
- kubernetesVersionsRendered, err := renderKubernetesVersions(options)
- if err != nil {
- return fmt.Errorf("render Kubernetes versions: %w", err)
+ content := []tables.Table{}
+ if options.AvailabilityZones != nil && len(*options.AvailabilityZones) != 0 {
+ content = append(content, buildAvailabilityZonesTable(options))
+ }
+
+ if options.KubernetesVersions != nil && len(*options.KubernetesVersions) != 0 {
+ kubernetesVersionsTable, err := buildKubernetesVersionsTable(options)
+ if err != nil {
+ return fmt.Errorf("build Kubernetes versions table: %w", err)
+ }
+ content = append(content, kubernetesVersionsTable)
+ }
+
+ if options.MachineImages != nil && len(*options.MachineImages) != 0 {
+ content = append(content, buildMachineImagesTable(options))
}
- content += kubernetesVersionsRendered
- content += renderMachineImages(options)
- content += renderMachineTypes(options)
- content += renderVolumeTypes(options)
+ if options.MachineTypes != nil && len(*options.MachineTypes) != 0 {
+ content = append(content, buildMachineTypesTable(options))
+ }
+
+ if options.VolumeTypes != nil && len(*options.VolumeTypes) != 0 {
+ content = append(content, buildVolumeTypesTable(options))
+ }
- err = p.PagerDisplay(content)
+ err := tables.DisplayTables(p, content)
if err != nil {
return fmt.Errorf("display output: %w", err)
}
@@ -199,11 +199,7 @@ func outputResultAsTable(p *print.Printer, options *ske.ProviderOptions) error {
return nil
}
-func renderAvailabilityZones(resp *ske.ProviderOptions) string {
- if resp.AvailabilityZones == nil {
- return ""
- }
-
+func buildAvailabilityZonesTable(resp *ske.ProviderOptions) tables.Table {
zones := *resp.AvailabilityZones
table := tables.NewTable()
@@ -213,14 +209,10 @@ func renderAvailabilityZones(resp *ske.ProviderOptions) string {
z := zones[i]
table.AddRow(*z.Name)
}
- return table.Render()
+ return table
}
-func renderKubernetesVersions(resp *ske.ProviderOptions) (string, error) {
- if resp.KubernetesVersions == nil {
- return "", nil
- }
-
+func buildKubernetesVersionsTable(resp *ske.ProviderOptions) (tables.Table, error) {
versions := *resp.KubernetesVersions
table := tables.NewTable()
@@ -230,22 +222,22 @@ func renderKubernetesVersions(resp *ske.ProviderOptions) (string, error) {
v := versions[i]
featureGate, err := json.Marshal(*v.FeatureGates)
if err != nil {
- return "", fmt.Errorf("marshal featureGates of Kubernetes version %q: %w", *v.Version, err)
+ return table, fmt.Errorf("marshal featureGates of Kubernetes version %q: %w", *v.Version, err)
}
expirationDate := ""
if v.ExpirationDate != nil {
- expirationDate = *v.ExpirationDate
+ expirationDate = v.ExpirationDate.Format(time.RFC3339)
}
- table.AddRow(*v.Version, *v.State, expirationDate, string(featureGate))
+ table.AddRow(
+ utils.PtrString(v.Version),
+ utils.PtrString(v.State),
+ expirationDate,
+ string(featureGate))
}
- return table.Render(), nil
+ return table, nil
}
-func renderMachineImages(resp *ske.ProviderOptions) string {
- if resp.MachineImages == nil {
- return ""
- }
-
+func buildMachineImagesTable(resp *ske.ProviderOptions) tables.Table {
images := *resp.MachineImages
table := tables.NewTable()
@@ -259,51 +251,53 @@ func renderMachineImages(resp *ske.ProviderOptions) string {
criNames := make([]string, 0)
for i := range *version.Cri {
cri := (*version.Cri)[i]
- criNames = append(criNames, *cri.Name)
+ criNames = append(criNames, string(*cri.Name))
}
criNamesString := strings.Join(criNames, ", ")
expirationDate := "-"
if version.ExpirationDate != nil {
- expirationDate = *version.ExpirationDate
+ expirationDate = version.ExpirationDate.Format(time.RFC3339)
}
- table.AddRow(*image.Name, *version.Version, *version.State, expirationDate, criNamesString)
+ table.AddRow(
+ utils.PtrString(image.Name),
+ utils.PtrString(version.Version),
+ utils.PtrString(version.State),
+ expirationDate,
+ criNamesString,
+ )
}
}
table.EnableAutoMergeOnColumns(1)
- return table.Render()
+ return table
}
-func renderMachineTypes(resp *ske.ProviderOptions) string {
- if resp.MachineTypes == nil {
- return ""
- }
-
- types := *resp.MachineTypes
+func buildMachineTypesTable(resp *ske.ProviderOptions) tables.Table {
+ machineTypes := *resp.MachineTypes
table := tables.NewTable()
table.SetTitle("Machine Types")
table.SetHeader("TYPE", "CPU", "MEMORY")
- for i := range types {
- t := types[i]
- table.AddRow(*t.Name, *t.Cpu, *t.Memory)
+ for i := range machineTypes {
+ t := machineTypes[i]
+ table.AddRow(
+ utils.PtrString(t.Name),
+ utils.PtrString(t.Cpu),
+ utils.PtrString(t.Memory),
+ )
}
- return table.Render()
+ return table
}
-func renderVolumeTypes(resp *ske.ProviderOptions) string {
- if resp.VolumeTypes == nil {
- return ""
- }
-
- types := *resp.VolumeTypes
+func buildVolumeTypesTable(resp *ske.ProviderOptions) tables.Table {
+ volumeTypes := *resp.VolumeTypes
table := tables.NewTable()
table.SetTitle("Volume Types")
table.SetHeader("TYPE")
- for i := range types {
- z := types[i]
- table.AddRow(*z.Name)
+ for i := range volumeTypes {
+ z := volumeTypes[i]
+ table.AddRow(utils.PtrString(z.Name))
}
- return table.Render()
+ return table
}
diff --git a/internal/cmd/ske/options/options_test.go b/internal/cmd/ske/options/options_test.go
index d6045f823..43f58c5b4 100644
--- a/internal/cmd/ske/options/options_test.go
+++ b/internal/cmd/ske/options/options_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"
@@ -17,6 +20,8 @@ type testCtxKey struct{}
var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
var testClient = &ske.APIClient{}
+const testRegion = "eu01"
+
func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
flagValues := map[string]string{
availabilityZonesFlag: "false",
@@ -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,13 +142,13 @@ func TestBuildRequest(t *testing.T) {
}{
{
description: "base",
- expectedRequest: testClient.ListProviderOptions(testCtx),
+ expectedRequest: testClient.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),
@@ -186,3 +160,97 @@ func TestBuildRequest(t *testing.T) {
})
}
}
+
+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 model",
+ args: args{
+ options: &ske.ProviderOptions{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing options",
+ args: args{
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing global flags in model",
+ args: args{
+ model: &inputModel{},
+ options: &ske.ProviderOptions{},
+ },
+ wantErr: true,
+ },
+ {
+ name: "set model and options",
+ args: args{
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{},
+ },
+ options: &ske.ProviderOptions{},
+ },
+ 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)
+ }
+ })
+ }
+}
+
+func TestOutputResultAsTable(t *testing.T) {
+ type args struct {
+ options *ske.ProviderOptions
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "empty options",
+ args: args{
+ options: &ske.ProviderOptions{},
+ },
+ 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 := outputResultAsTable(p, tt.args.options); (err != nil) != tt.wantErr {
+ t.Errorf("outputResultAsTable() 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..fc9e0b3cd
--- /dev/null
+++ b/internal/cmd/volume/backup/create/create.go
@@ -0,0 +1,204 @@
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Creating backup")
+ resp, err = wait.CreateBackupWaitHandler(ctx, apiClient, model.ProjectId, model.Region, volumeId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for backup creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..9c9717427
--- /dev/null
+++ b/internal/cmd/volume/backup/delete/delete.go
@@ -0,0 +1,116 @@
+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"
+
+ iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting backup")
+ _, err = wait.DeleteBackupWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.BackupId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for backup deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..c8c1b66d1
--- /dev/null
+++ b/internal/cmd/volume/backup/describe/describe.go
@@ -0,0 +1,136 @@
+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..c434b3df5
--- /dev/null
+++ b/internal/cmd/volume/backup/list/list.go
@@ -0,0 +1,176 @@
+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/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"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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..63fc0d915
--- /dev/null
+++ b/internal/cmd/volume/backup/restore/restore.go
@@ -0,0 +1,129 @@
+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"
+
+ iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait"
+)
+
+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 {
+ s := spinner.New(params.Printer)
+ s.Start("Restoring backup")
+ _, err = wait.RestoreBackupWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.BackupId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for backup restore: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..9141f9a2b
--- /dev/null
+++ b/internal/cmd/volume/backup/update/update.go
@@ -0,0 +1,140 @@
+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
new file mode 100644
index 000000000..f0bda5f69
--- /dev/null
+++ b/internal/cmd/volume/create/create.go
@@ -0,0 +1,190 @@
+package create
+
+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"
+ "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/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 (
+ availabilityZoneFlag = "availability-zone"
+ nameFlag = "name"
+ descriptionFlag = "description"
+ labelFlag = "labels"
+ performanceClassFlag = "performance-class"
+ sizeFlag = "size"
+ sourceIdFlag = "source-id"
+ sourceTypeFlag = "source-type"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ AvailabilityZone *string
+ Name *string
+ Description *string
+ Labels *map[string]string
+ PerformanceClass *string
+ Size *int64
+ SourceId *string
+ SourceType *string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Creates a volume",
+ Long: "Creates a volume.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Create a volume with availability zone "eu01-1" and size 64 GB`,
+ `$ stackit volume create --availability-zone eu01-1 --size 64`,
+ ),
+ examples.NewExample(
+ `Create a volume with availability zone "eu01-1", size 64 GB and labels`,
+ `$ stackit volume create --availability-zone eu01-1 --size 64 --labels key=value,foo=bar`,
+ ),
+ examples.NewExample(
+ `Create a volume with name "volume-1", from a source image with ID "xxx"`,
+ `$ stackit volume create --availability-zone eu01-1 --name volume-1 --source-id xxx --source-type image`,
+ ),
+ examples.NewExample(
+ `Create a volume with availability zone "eu01-1", performance class "storage_premium_perf1" and size 64 GB`,
+ `$ stackit volume create --availability-zone eu01-1 --performance-class storage_premium_perf1 --size 64`,
+ ),
+ ),
+ 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 volume 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 volume : %w", err)
+ }
+ volumeId := *resp.Id
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Creating volume")
+ _, err = wait.CreateVolumeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, volumeId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for volume creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ return outputResult(params.Printer, model, projectLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().String(availabilityZoneFlag, "", "Availability zone")
+ cmd.Flags().StringP(nameFlag, "n", "", "Volume name")
+ cmd.Flags().String(descriptionFlag, "", "Volume description")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a volume. E.g. '--labels key1=value1,key2=value2,...'")
+ cmd.Flags().String(performanceClassFlag, "", "Performance class")
+ cmd.Flags().Int64(sizeFlag, 0, "Volume size (GB). Either 'size' or the 'source-id' and 'source-type' flags must be given")
+ cmd.Flags().String(sourceIdFlag, "", "ID of the source object of volume. Either 'size' or the 'source-id' and 'source-type' flags must be given")
+ cmd.Flags().String(sourceTypeFlag, "", "Type of the source object of volume. Either 'size' or the 'source-id' and 'source-type' flags must be given")
+
+ err := flags.MarkFlagsRequired(cmd, availabilityZoneFlag)
+ 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,
+ AvailabilityZone: flags.FlagToStringPointer(p, cmd, availabilityZoneFlag),
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ Description: flags.FlagToStringPointer(p, cmd, descriptionFlag),
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelFlag),
+ PerformanceClass: flags.FlagToStringPointer(p, cmd, performanceClassFlag),
+ Size: flags.FlagToInt64Pointer(p, cmd, sizeFlag),
+ SourceId: flags.FlagToStringPointer(p, cmd, sourceIdFlag),
+ SourceType: flags.FlagToStringPointer(p, cmd, sourceTypeFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateVolumeRequest {
+ req := apiClient.CreateVolume(ctx, model.ProjectId, model.Region)
+ source := &iaas.VolumeSource{
+ Id: model.SourceId,
+ Type: model.SourceType,
+ }
+
+ payload := iaas.CreateVolumePayload{
+ AvailabilityZone: model.AvailabilityZone,
+ Name: model.Name,
+ Description: model.Description,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ PerformanceClass: model.PerformanceClass,
+ Size: model.Size,
+ }
+
+ if model.SourceId != nil && model.SourceType != nil {
+ payload.Source = source
+ }
+
+ return req.CreateVolumePayload(payload)
+}
+
+func outputResult(p *print.Printer, model *inputModel, projectLabel string, volume *iaas.Volume) error {
+ if volume == nil {
+ return fmt.Errorf("volume response is empty")
+ }
+ return p.OutputResult(model.OutputFormat, volume, func() error {
+ p.Outputf("Created volume for project %q.\nVolume ID: %s\n", 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
new file mode 100644
index 000000000..9628fd508
--- /dev/null
+++ b/internal/cmd/volume/create/create_test.go
@@ -0,0 +1,292 @@
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testSourceId = uuid.NewString()
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ availabilityZoneFlag: "eu01-1",
+ nameFlag: "example-volume-name",
+ descriptionFlag: "example-volume-description",
+ labelFlag: "key=value",
+ performanceClassFlag: "example-perf-class",
+ sizeFlag: "5",
+ sourceIdFlag: testSourceId,
+ sourceTypeFlag: "example-source-type",
+ }
+ 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,
+ },
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ Name: utils.Ptr("example-volume-name"),
+ Description: utils.Ptr("example-volume-description"),
+ PerformanceClass: utils.Ptr("example-perf-class"),
+ Size: utils.Ptr(int64(5)),
+ SourceId: utils.Ptr(testSourceId),
+ SourceType: utils.Ptr("example-source-type"),
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiCreateVolumeRequest)) iaas.ApiCreateVolumeRequest {
+ request := testClient.CreateVolume(testCtx, testProjectId, testRegion)
+ request = request.CreateVolumePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixtureRequiredRequest(mods ...func(request *iaas.ApiCreateVolumeRequest)) iaas.ApiCreateVolumeRequest {
+ request := testClient.CreateVolume(testCtx, testProjectId, testRegion)
+ request = request.CreateVolumePayload(iaas.CreateVolumePayload{
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ })
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.CreateVolumePayload)) iaas.CreateVolumePayload {
+ payload := iaas.CreateVolumePayload{
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ Name: utils.Ptr("example-volume-name"),
+ Description: utils.Ptr("example-volume-description"),
+ PerformanceClass: utils.Ptr("example-perf-class"),
+ Size: utils.Ptr(int64(5)),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ Source: &iaas.VolumeSource{
+ Id: utils.Ptr(testSourceId),
+ Type: utils.Ptr("example-source-type"),
+ },
+ }
+ 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, nameFlag)
+ delete(flagValues, descriptionFlag)
+ delete(flagValues, labelFlag)
+ delete(flagValues, performanceClassFlag)
+ delete(flagValues, sizeFlag)
+ delete(flagValues, sourceIdFlag)
+ delete(flagValues, sourceTypeFlag)
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Name = nil
+ model.Description = nil
+ model.Labels = nil
+ model.PerformanceClass = nil
+ model.Size = nil
+ model.SourceType = nil
+ model.SourceId = nil
+ }),
+ },
+ {
+ description: "availability zone missing",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ delete(flagValues, availabilityZoneFlag)
+ }),
+ isValid: false,
+ },
+ {
+ 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: "use performance class and size",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[performanceClassFlag] = "example-perf-class"
+ flagValues[sizeFlag] = "5"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.PerformanceClass = utils.Ptr("example-perf-class")
+ model.Size = utils.Ptr(int64(5))
+ }),
+ },
+ {
+ description: "use source id and type",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[sourceIdFlag] = testSourceId
+ flagValues[sourceTypeFlag] = "example-source-type"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.SourceId = utils.Ptr(testSourceId)
+ model.SourceType = utils.Ptr("example-source-type")
+ }),
+ },
+ }
+
+ 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.ApiCreateVolumeRequest
+ }{
+ {
+ description: "base",
+ model: fixtureInputModel(),
+ expectedRequest: fixtureRequest(),
+ },
+ {
+ description: "only availability zone in payload",
+ model: &inputModel{
+ GlobalFlagModel: &globalflags.GlobalFlagModel{
+ ProjectId: testProjectId,
+ Verbosity: globalflags.VerbosityDefault,
+ Region: testRegion,
+ },
+ AvailabilityZone: utils.Ptr("eu01-1"),
+ },
+ expectedRequest: fixtureRequiredRequest(),
+ },
+ }
+
+ 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
+ volume *iaas.Volume
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "volume as argument",
+ args: args{
+ model: fixtureInputModel(),
+ volume: &iaas.Volume{},
+ },
+ 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.volume); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/volume/delete/delete.go b/internal/cmd/volume/delete/delete.go
new file mode 100644
index 000000000..599f3bf56
--- /dev/null
+++ b/internal/cmd/volume/delete/delete.go
@@ -0,0 +1,121 @@
+package delete
+
+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"
+ "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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("delete %s", volumeIdArg),
+ Short: "Deletes a volume",
+ Long: fmt.Sprintf("%s\n%s\n",
+ "Deletes a volume.",
+ "If the volume is still in use, the deletion will fail",
+ ),
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Delete volume with ID "xxx"`,
+ "$ stackit volume 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
+ }
+
+ 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 delete volume %q?", volumeLabel)
+ 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 volume: %w", err)
+ }
+
+ // Wait for async operation, if async mode not enabled
+ if !model.Async {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting volume")
+ _, err = wait.DeleteVolumeWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.VolumeId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for volume deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ operationState := "Deleted"
+ if model.Async {
+ operationState = "Triggered deletion of"
+ }
+ params.Printer.Info("%s volume %q\n", operationState, volumeLabel)
+ return nil
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ VolumeId: volumeId,
+ }
+
+ 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.Region, model.VolumeId)
+}
diff --git a/internal/cmd/volume/delete/delete_test.go b/internal/cmd/volume/delete/delete_test.go
new file mode 100644
index 000000000..42d63db2b
--- /dev/null
+++ b/internal/cmd/volume/delete/delete_test.go
@@ -0,0 +1,175 @@
+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")
+var testClient = &iaas.APIClient{}
+var testVolumeId = uuid.NewString()
+var testProjectId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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{
+ Verbosity: globalflags.VerbosityDefault,
+ ProjectId: testProjectId,
+ Region: testRegion,
+ },
+ VolumeId: testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiDeleteVolumeRequest)) iaas.ApiDeleteVolumeRequest {
+ request := testClient.DeleteVolume(testCtx, testProjectId, testRegion, testVolumeId)
+ 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: "volume id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "volume 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 iaas.ApiDeleteVolumeRequest
+ }{
+ {
+ 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/describe/describe.go b/internal/cmd/volume/describe/describe.go
new file mode 100644
index 000000000..8ff8dbfab
--- /dev/null
+++ b/internal/cmd/volume/describe/describe.go
@@ -0,0 +1,140 @@
+package describe
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeId string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", volumeIdArg),
+ Short: "Shows details of a volume",
+ Long: "Shows details of a volume.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a volume with ID "xxx"`,
+ "$ stackit volume describe xxx",
+ ),
+ examples.NewExample(
+ `Show details of a volume with ID "xxx" in JSON format`,
+ "$ stackit volume 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 volume: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ VolumeId: volumeId,
+ }
+
+ 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.Region, model.VolumeId)
+}
+
+func outputResult(p *print.Printer, outputFormat string, volume *iaas.Volume) error {
+ if volume == nil {
+ return fmt.Errorf("volume response is empty")
+ }
+ return p.OutputResult(outputFormat, volume, func() error {
+ table := tables.NewTable()
+ table.AddRow("ID", utils.PtrString(volume.Id))
+ table.AddSeparator()
+ table.AddRow("NAME", utils.PtrString(volume.Name))
+ table.AddSeparator()
+ table.AddRow("STATE", utils.PtrString(volume.Status))
+ table.AddSeparator()
+ table.AddRow("VOLUME SIZE (GB)", utils.PtrString(volume.Size))
+ table.AddSeparator()
+ table.AddRow("PERFORMANCE CLASS", utils.PtrString(volume.PerformanceClass))
+ table.AddSeparator()
+ table.AddRow("AVAILABILITY ZONE", utils.PtrString(volume.AvailabilityZone))
+ table.AddSeparator()
+
+ if volume.Source != nil {
+ sourceId := *volume.Source.Id
+ table.AddRow("SOURCE", sourceId)
+ table.AddSeparator()
+ }
+
+ if volume.ServerId != nil {
+ serverId := *volume.ServerId
+ table.AddRow("SERVER", serverId)
+ table.AddSeparator()
+ }
+
+ if volume.Labels != nil && len(*volume.Labels) > 0 {
+ labels := []string{}
+ for key, value := range *volume.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..eb595b37e
--- /dev/null
+++ b/internal/cmd/volume/describe/describe_test.go
@@ -0,0 +1,211 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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,
+ },
+ VolumeId: testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetVolumeRequest)) iaas.ApiGetVolumeRequest {
+ request := testClient.GetVolume(testCtx, testProjectId, testRegion, testVolumeId)
+ 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: "volume id invalid 1",
+ argValues: []string{""},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "volume 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 iaas.ApiGetVolumeRequest
+ }{
+ {
+ 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
+ volume *iaas.Volume
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "volume as argument",
+ args: args{
+ volume: &iaas.Volume{},
+ },
+ 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.volume); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/volume/list/list.go b/internal/cmd/volume/list/list.go
new file mode 100644
index 000000000..5d82bad1a
--- /dev/null
+++ b/internal/cmd/volume/list/list.go
@@ -0,0 +1,160 @@
+package list
+
+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"
+ "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 (
+ 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 volumes of a project",
+ Long: "Lists all volumes of a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all volumes`,
+ "$ stackit volume list",
+ ),
+ examples.NewExample(
+ `Lists all volumes which contains the label xxx`,
+ "$ stackit volume list --label-selector xxx",
+ ),
+ examples.NewExample(
+ `Lists all volumes in JSON format`,
+ "$ stackit volume list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 volumes`,
+ "$ stackit volume 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("list volumes: %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 volumes found for project %q\n", projectLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListVolumesRequest {
+ req := apiClient.ListVolumes(ctx, model.ProjectId, model.Region)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, volumes []iaas.Volume) error {
+ return p.OutputResult(outputFormat, volumes, func() error {
+ table := tables.NewTable()
+ table.SetHeader("ID", "Name", "Status", "Server", "Availability Zone", "Size (GB)")
+
+ for i := range volumes {
+ volume := volumes[i]
+ table.AddRow(
+ utils.PtrString(volume.Id),
+ utils.PtrString(volume.Name),
+ utils.PtrString(volume.Status),
+ utils.PtrString(volume.ServerId),
+ utils.PtrString(volume.AvailabilityZone),
+ utils.PtrString(volume.Size),
+ )
+ table.AddSeparator()
+ }
+
+ p.Outputln(table.Render())
+ return nil
+ })
+}
diff --git a/internal/cmd/volume/list/list_test.go b/internal/cmd/volume/list/list_test.go
new file mode 100644
index 000000000..d81ee310f
--- /dev/null
+++ b/internal/cmd/volume/list/list_test.go
@@ -0,0 +1,207 @@
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testLabelSelector = "label"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ limitFlag: "10",
+ labelSelectorFlag: testLabelSelector,
+ }
+ 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,
+ },
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListVolumesRequest)) iaas.ApiListVolumesRequest {
+ request := testClient.ListVolumes(testCtx, testProjectId, testRegion)
+ request = request.LabelSelector(testLabelSelector)
+ 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 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: "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: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ 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.ApiListVolumesRequest
+ }{
+ {
+ 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
+ volumes []iaas.Volume
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty volume",
+ args: args{
+ volumes: []iaas.Volume{{}},
+ },
+ 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.volumes); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/volume/performance-class/describe/describe.go b/internal/cmd/volume/performance-class/describe/describe.go
new file mode 100644
index 000000000..23c763e84
--- /dev/null
+++ b/internal/cmd/volume/performance-class/describe/describe.go
@@ -0,0 +1,125 @@
+package describe
+
+import (
+ "context"
+ "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"
+ "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/spf13/cobra"
+)
+
+const (
+ volumePerformanceClassArg = "VOLUME_PERFORMANCE_CLASS"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumePerformanceClass string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("describe %s", volumePerformanceClassArg),
+ Short: "Shows details of a volume performance class",
+ Long: "Shows details of a volume performance class.",
+ Args: args.SingleArg(volumePerformanceClassArg, nil),
+ Example: examples.Build(
+ examples.NewExample(
+ `Show details of a volume performance class with name "xxx"`,
+ "$ stackit volume performance-class describe xxx",
+ ),
+ examples.NewExample(
+ `Show details of a volume performance class with name "xxx" in JSON format`,
+ "$ stackit volume performance-class 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 volume performance class: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, resp)
+ },
+ }
+ return cmd
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumePerformanceClass := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &errors.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ VolumePerformanceClass: volumePerformanceClass,
+ }
+
+ 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.Region, model.VolumePerformanceClass)
+}
+
+func outputResult(p *print.Printer, outputFormat string, performanceClass *iaas.VolumePerformanceClass) error {
+ if performanceClass == nil {
+ return fmt.Errorf("performanceClass response is empty")
+ }
+ return p.OutputResult(outputFormat, performanceClass, func() error {
+ table := tables.NewTable()
+ table.AddRow("NAME", utils.PtrString(performanceClass.Name))
+ table.AddSeparator()
+ table.AddRow("DESCRIPTION", utils.PtrString(performanceClass.Description))
+ table.AddSeparator()
+ table.AddRow("IOPS", utils.PtrString(performanceClass.Iops))
+ table.AddSeparator()
+ table.AddRow("THROUGHPUT", utils.PtrString(performanceClass.Throughput))
+ table.AddSeparator()
+
+ if performanceClass.Labels != nil && len(*performanceClass.Labels) > 0 {
+ labels := []string{}
+ for key, value := range *performanceClass.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+ table.AddRow("LABELS", strings.Join(labels, "\n"))
+ table.AddSeparator()
+ }
+
+ err := table.Display(p)
+ if err != nil {
+ 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
new file mode 100644
index 000000000..d24d96eaf
--- /dev/null
+++ b/internal/cmd/volume/performance-class/describe/describe_test.go
@@ -0,0 +1,206 @@
+package describe
+
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testVolumePerformanceClass = "storage_premium_perf6"
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumePerformanceClass,
+ }
+ 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,
+ },
+ VolumePerformanceClass: testVolumePerformanceClass,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiGetVolumePerformanceClassRequest)) iaas.ApiGetVolumePerformanceClassRequest {
+ request := testClient.GetVolumePerformanceClass(testCtx, testProjectId, testRegion, testVolumePerformanceClass)
+ 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: "volume performance class invalid 1",
+ argValues: []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.argValues, tt.flagValues, tt.isValid)
+ })
+ }
+}
+
+func TestBuildRequest(t *testing.T) {
+ tests := []struct {
+ description string
+ model *inputModel
+ expectedRequest iaas.ApiGetVolumePerformanceClassRequest
+ }{
+ {
+ 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
+ performanceClass *iaas.VolumePerformanceClass
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "volume performance class as argument",
+ args: args{
+ performanceClass: &iaas.VolumePerformanceClass{},
+ },
+ 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.performanceClass); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/volume/performance-class/list/list.go b/internal/cmd/volume/performance-class/list/list.go
new file mode 100644
index 000000000..7062011aa
--- /dev/null
+++ b/internal/cmd/volume/performance-class/list/list.go
@@ -0,0 +1,153 @@
+package list
+
+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"
+ "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"
+)
+
+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 volume performance classes for a project",
+ Long: "Lists all volume performance classes for a project.",
+ Args: args.NoArgs,
+ Example: examples.Build(
+ examples.NewExample(
+ `Lists all volume performance classes`,
+ "$ stackit volume performance-class list",
+ ),
+ examples.NewExample(
+ `Lists all volume performance classes which contains the label xxx`,
+ "$ stackit volume performance-class list --label-selector xxx",
+ ),
+ examples.NewExample(
+ `Lists all volume performance classes in JSON format`,
+ "$ stackit volume performance-class list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 volume performance classes`,
+ "$ stackit volume performance-class 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("list volume performance classes: %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 volume performance class found for project %q\n", projectLabel)
+ return nil
+ }
+
+ // Truncate output
+ items := *resp.Items
+ 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().Int64(limitFlag, 0, "Maximum number of entries to list")
+ cmd.Flags().String(labelSelectorFlag, "", "Filter by label")
+}
+
+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,
+ LabelSelector: flags.FlagToStringPointer(p, cmd, labelSelectorFlag),
+ }
+
+ p.DebugInputModel(model)
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListVolumePerformanceClassesRequest {
+ req := apiClient.ListVolumePerformanceClasses(ctx, model.ProjectId, model.Region)
+ if model.LabelSelector != nil {
+ req = req.LabelSelector(*model.LabelSelector)
+ }
+
+ return req
+}
+
+func outputResult(p *print.Printer, outputFormat string, performanceClasses []iaas.VolumePerformanceClass) error {
+ return p.OutputResult(outputFormat, performanceClasses, func() error {
+ table := tables.NewTable()
+ table.SetHeader("Name", "Description")
+
+ for _, performanceClass := range performanceClasses {
+ table.AddRow(utils.PtrString(performanceClass.Name), utils.PtrString(performanceClass.Description))
+ table.AddSeparator()
+ }
+
+ 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
new file mode 100644
index 000000000..53004a31b
--- /dev/null
+++ b/internal/cmd/volume/performance-class/list/list_test.go
@@ -0,0 +1,207 @@
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+var testProjectId = uuid.NewString()
+var testLabelSelector = "label"
+
+func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string {
+ flagValues := map[string]string{
+ globalflags.ProjectIdFlag: testProjectId,
+ globalflags.RegionFlag: testRegion,
+
+ limitFlag: "10",
+ labelSelectorFlag: testLabelSelector,
+ }
+ 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,
+ },
+ Limit: utils.Ptr(int64(10)),
+ LabelSelector: utils.Ptr(testLabelSelector),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiListVolumePerformanceClassesRequest)) iaas.ApiListVolumePerformanceClassesRequest {
+ request := testClient.ListVolumePerformanceClasses(testCtx, testProjectId, testRegion)
+ request = request.LabelSelector(testLabelSelector)
+ 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 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: "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: "label selector empty",
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelSelectorFlag] = ""
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.LabelSelector = utils.Ptr("")
+ }),
+ },
+ }
+
+ 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.ApiListVolumePerformanceClassesRequest
+ }{
+ {
+ 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
+ performanceClasses []iaas.VolumePerformanceClass
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: false,
+ },
+ {
+ name: "set empty volume",
+ args: args{
+ performanceClasses: []iaas.VolumePerformanceClass{{}},
+ },
+ 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.performanceClasses); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/volume/performance-class/performance_class.go b/internal/cmd/volume/performance-class/performance_class.go
new file mode 100644
index 000000000..dd00fe2d6
--- /dev/null
+++ b/internal/cmd/volume/performance-class/performance_class.go
@@ -0,0 +1,28 @@
+package performanceclass
+
+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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "performance-class",
+ Short: "Provides functionality for volume performance classes available inside a project",
+ Long: "Provides functionality for volume performance classes available inside a project.",
+ 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))
+}
diff --git a/internal/cmd/volume/resize/resize.go b/internal/cmd/volume/resize/resize.go
new file mode 100644
index 000000000..0bfbc0797
--- /dev/null
+++ b/internal/cmd/volume/resize/resize.go
@@ -0,0 +1,120 @@
+package resize
+
+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"
+ "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"
+
+ "github.com/spf13/cobra"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+
+ sizeFlag = "size"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeId string
+ Size *int64
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("resize %s", volumeIdArg),
+ Short: "Resizes a volume",
+ Long: "Resizes a volume.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Resize volume with ID "xxx" with new size 10 GB`,
+ `$ stackit volume resize xxx --size 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
+ }
+
+ 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 resize volume %q?", volumeLabel)
+ 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("resize volume: %w", err)
+ }
+
+ params.Printer.Outputf("Resized volume %q.\n", volumeLabel)
+ return nil
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().Int64(sizeFlag, 0, "Volume size (GB)")
+
+ err := flags.MarkFlagsRequired(cmd, sizeFlag)
+ cobra.CheckErr(err)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Size: flags.FlagToInt64Pointer(p, cmd, sizeFlag),
+ VolumeId: volumeId,
+ }
+
+ 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.Region, model.VolumeId)
+
+ payload := iaas.ResizeVolumePayload{
+ Size: model.Size,
+ }
+
+ return req.ResizeVolumePayload(payload)
+}
diff --git a/internal/cmd/volume/resize/resize_test.go b/internal/cmd/volume/resize/resize_test.go
new file mode 100644
index 000000000..a45ecb6ea
--- /dev/null
+++ b/internal/cmd/volume/resize/resize_test.go
@@ -0,0 +1,239 @@
+package resize
+
+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/iaas"
+)
+
+const (
+ testRegion = "eu01"
+)
+
+type testCtxKey struct{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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,
+
+ sizeFlag: "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,
+ },
+ Size: utils.Ptr(int64(10)),
+ VolumeId: testVolumeId,
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiResizeVolumeRequest)) iaas.ApiResizeVolumeRequest {
+ request := testClient.ResizeVolume(testCtx, testProjectId, testRegion, testVolumeId)
+ request = request.ResizeVolumePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.ResizeVolumePayload)) iaas.ResizeVolumePayload {
+ payload := iaas.ResizeVolumePayload{
+ Size: utils.Ptr(int64(10)),
+ }
+ 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: "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: "volume id invalid 1",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "resize",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[sizeFlag] = "15"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Size = utils.Ptr(int64(15))
+ }),
+ },
+ }
+
+ 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 iaas.ApiResizeVolumeRequest
+ }{
+ {
+ 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/create/create.go b/internal/cmd/volume/snapshot/create/create.go
new file mode 100644
index 000000000..f63a38b16
--- /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 {
+ s := spinner.New(params.Printer)
+ s.Start("Creating snapshot")
+ resp, err = wait.CreateSnapshotWaitHandler(ctx, apiClient, model.ProjectId, model.Region, *resp.Id).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for snapshot creation: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..d2ee79608
--- /dev/null
+++ b/internal/cmd/volume/snapshot/create/create_test.go
@@ -0,0 +1,172 @@
+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-cli/internal/pkg/globalflags"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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..7334cac51
--- /dev/null
+++ b/internal/cmd/volume/snapshot/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"
+ 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 {
+ s := spinner.New(params.Printer)
+ s.Start("Deleting snapshot")
+ _, err = wait.DeleteSnapshotWaitHandler(ctx, apiClient, model.ProjectId, model.Region, model.SnapshotId).WaitWithContext(ctx)
+ if err != nil {
+ return fmt.Errorf("wait for snapshot deletion: %w", err)
+ }
+ s.Stop()
+ }
+
+ 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..c87336496
--- /dev/null
+++ b/internal/cmd/volume/snapshot/describe/describe.go
@@ -0,0 +1,131 @@
+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..70b97edf2
--- /dev/null
+++ b/internal/cmd/volume/snapshot/list/list.go
@@ -0,0 +1,172 @@
+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-cli/internal/pkg/projectname"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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..579233ca9
--- /dev/null
+++ b/internal/cmd/volume/snapshot/snapshot.go
@@ -0,0 +1,33 @@
+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..f48649743
--- /dev/null
+++ b/internal/cmd/volume/snapshot/update/update.go
@@ -0,0 +1,134 @@
+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
new file mode 100644
index 000000000..3c8f447d3
--- /dev/null
+++ b/internal/cmd/volume/update/update.go
@@ -0,0 +1,143 @@
+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"
+ 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/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+const (
+ volumeIdArg = "VOLUME_ID"
+
+ nameFlag = "name"
+ descriptionFlag = "description"
+ labelFlag = "labels"
+)
+
+type inputModel struct {
+ *globalflags.GlobalFlagModel
+ VolumeId string
+ Name *string
+ Description *string
+ Labels *map[string]string
+}
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: fmt.Sprintf("update %s", volumeIdArg),
+ Short: "Updates a volume",
+ Long: "Updates a volume.",
+ Args: args.SingleArg(volumeIdArg, utils.ValidateUUID),
+ Example: examples.Build(
+ examples.NewExample(
+ `Update volume with ID "xxx" with new name "volume-1-new"`,
+ `$ stackit volume update xxx --name volume-1-new`,
+ ),
+ examples.NewExample(
+ `Update volume with ID "xxx" with new name "volume-1-new" and new description "volume-1-desc-new"`,
+ `$ stackit volume update xxx --name volume-1-new --description volume-1-desc-new`,
+ ),
+ examples.NewExample(
+ `Update volume with ID "xxx" with new name "volume-1-new", new description "volume-1-desc-new" and label(s)`,
+ `$ stackit volume update xxx --name volume-1-new --description volume-1-desc-new --labels key=value,foo=bar`,
+ ),
+ ),
+ 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
+ }
+
+ 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 update volume %q?", 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("update volume: %w", err)
+ }
+
+ return outputResult(params.Printer, model.OutputFormat, volumeLabel, resp)
+ },
+ }
+ configureFlags(cmd)
+ return cmd
+}
+
+func configureFlags(cmd *cobra.Command) {
+ cmd.Flags().StringP(nameFlag, "n", "", "Volume name")
+ cmd.Flags().String(descriptionFlag, "", "Volume description")
+ cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a volume. E.g. '--labels key1=value1,key2=value2,...'")
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ volumeId := inputArgs[0]
+
+ globalFlags := globalflags.Parse(p, cmd)
+ if globalFlags.ProjectId == "" {
+ return nil, &cliErr.ProjectIdError{}
+ }
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Name: flags.FlagToStringPointer(p, cmd, nameFlag),
+ VolumeId: volumeId,
+ 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 *iaas.APIClient) iaas.ApiUpdateVolumeRequest {
+ req := apiClient.UpdateVolume(ctx, model.ProjectId, model.Region, model.VolumeId)
+
+ payload := iaas.UpdateVolumePayload{
+ Name: model.Name,
+ Description: model.Description,
+ Labels: utils.ConvertStringMapToInterfaceMap(model.Labels),
+ }
+
+ return req.UpdateVolumePayload(payload)
+}
+
+func outputResult(p *print.Printer, outputFormat, volumeLabel string, volume *iaas.Volume) error {
+ if volume == nil {
+ return fmt.Errorf("volume response is empty")
+ }
+ 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
new file mode 100644
index 000000000..0f34841aa
--- /dev/null
+++ b/internal/cmd/volume/update/update_test.go
@@ -0,0 +1,298 @@
+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-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{}
+
+var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo")
+var testClient = &iaas.APIClient{}
+
+var testProjectId = uuid.NewString()
+var testVolumeId = uuid.NewString()
+
+func fixtureArgValues(mods ...func(argValues []string)) []string {
+ argValues := []string{
+ testVolumeId,
+ }
+ 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: "example-volume-name",
+ descriptionFlag: "example-volume-desc",
+ labelFlag: "key=value",
+ }
+ 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,
+ },
+ Name: utils.Ptr("example-volume-name"),
+ Description: utils.Ptr("example-volume-desc"),
+ VolumeId: testVolumeId,
+ Labels: utils.Ptr(map[string]string{
+ "key": "value",
+ }),
+ }
+ for _, mod := range mods {
+ mod(model)
+ }
+ return model
+}
+
+func fixtureRequest(mods ...func(request *iaas.ApiUpdateVolumeRequest)) iaas.ApiUpdateVolumeRequest {
+ request := testClient.UpdateVolume(testCtx, testProjectId, testRegion, testVolumeId)
+ request = request.UpdateVolumePayload(fixturePayload())
+ for _, mod := range mods {
+ mod(&request)
+ }
+ return request
+}
+
+func fixturePayload(mods ...func(payload *iaas.UpdateVolumePayload)) iaas.UpdateVolumePayload {
+ payload := iaas.UpdateVolumePayload{
+ Name: utils.Ptr("example-volume-name"),
+ Description: utils.Ptr("example-volume-desc"),
+ Labels: utils.Ptr(map[string]interface{}{
+ "key": "value",
+ }),
+ }
+ 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: "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: "volume id invalid 1",
+ argValues: []string{},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "volume id invalid 2",
+ argValues: []string{"invalid-uuid"},
+ flagValues: fixtureFlagValues(),
+ isValid: false,
+ },
+ {
+ description: "use name and description",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[nameFlag] = "example-volume-name"
+ flagValues[descriptionFlag] = "example-volume-desc"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Name = utils.Ptr("example-volume-name")
+ model.Description = utils.Ptr("example-volume-desc")
+ }),
+ },
+ {
+ description: "use labels",
+ argValues: fixtureArgValues(),
+ flagValues: fixtureFlagValues(func(flagValues map[string]string) {
+ flagValues[labelFlag] = "key=value"
+ }),
+ isValid: true,
+ expectedModel: fixtureInputModel(func(model *inputModel) {
+ model.Labels = &map[string]string{
+ "key": "value",
+ }
+ }),
+ },
+ }
+
+ 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 iaas.ApiUpdateVolumeRequest
+ }{
+ {
+ 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
+ volumeLabel string
+ volume *iaas.Volume
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "empty",
+ args: args{},
+ wantErr: true,
+ },
+ {
+ name: "volume as argument",
+ args: args{
+ volume: &iaas.Volume{},
+ },
+ 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.volumeLabel, tt.args.volume); (err != nil) != tt.wantErr {
+ t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/volume/volume.go b/internal/cmd/volume/volume.go
new file mode 100644
index 000000000..a6967a9ae
--- /dev/null
+++ b/internal/cmd/volume/volume.go
@@ -0,0 +1,42 @@
+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/types"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/spf13/cobra"
+)
+
+func NewCmd(params *types.CmdParams) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "volume",
+ Short: "Provides functionality for volumes",
+ Long: "Provides functionality for volumes.",
+ 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))
+ 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/args/args_test.go b/internal/pkg/args/args_test.go
index 2a1360b44..2afe2b8d3 100644
--- a/internal/pkg/args/args_test.go
+++ b/internal/pkg/args/args_test.go
@@ -53,7 +53,7 @@ func TestSingleArg(t *testing.T) {
{
description: "valid",
args: []string{"arg"},
- validateFunc: func(value string) error {
+ validateFunc: func(_ string) error {
return nil
},
isValid: true,
@@ -76,7 +76,7 @@ func TestSingleArg(t *testing.T) {
{
description: "invalid_arg",
args: []string{"arg"},
- validateFunc: func(value string) error {
+ validateFunc: func(_ string) error {
return fmt.Errorf("error")
},
isValid: false,
@@ -118,7 +118,7 @@ func TestSingleOptionalArg(t *testing.T) {
{
description: "valid",
args: []string{"arg"},
- validateFunc: func(value string) error {
+ validateFunc: func(_ string) error {
return nil
},
isValid: true,
@@ -141,7 +141,7 @@ func TestSingleOptionalArg(t *testing.T) {
{
description: "invalid_arg",
args: []string{"arg"},
- validateFunc: func(value string) error {
+ validateFunc: func(_ string) error {
return fmt.Errorf("error")
},
isValid: false,
diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go
index 7416cb6eb..dd56536d3 100644
--- a/internal/pkg/auth/auth.go
+++ b/internal/pkg/auth/auth.go
@@ -2,6 +2,8 @@ package auth
import (
"fmt"
+ "net/http"
+ "os"
"strconv"
"time"
@@ -22,7 +24,15 @@ type tokenClaims struct {
// It returns the configuration option that can be used to create an authenticated SDK client.
//
// If the user was logged in and the user session expired, reauthorizeUserRoutine is called to reauthenticate the user again.
-func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print.Printer, isReauthentication bool) error) (authCfgOption sdkConfig.ConfigurationOption, err error) {
+// 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) {
+ // Get access token from env and use this if present
+ accessToken := os.Getenv(envAccessTokenName)
+ if accessToken != "" {
+ authCfgOption = sdkConfig.WithToken(accessToken)
+ return authCfgOption, nil
+ }
+
flow, err := GetAuthFlow()
if err != nil {
return nil, fmt.Errorf("get authentication flow: %w", err)
@@ -31,7 +41,7 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print
return nil, fmt.Errorf("authentication flow not set")
}
- userSessionExpired, err := userSessionExpired()
+ userSessionExpired, err := UserSessionExpired()
if err != nil {
return nil, fmt.Errorf("check if user session expired: %w", err)
}
@@ -42,7 +52,7 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print
if userSessionExpired {
return nil, fmt.Errorf("session expired")
}
- accessToken, err := getAccessToken()
+ accessToken, err := GetAccessToken()
if err != nil {
return nil, fmt.Errorf("get service account access token: %w", err)
}
@@ -73,7 +83,7 @@ func AuthenticationConfig(p *print.Printer, reauthorizeUserRoutine func(p *print
return authCfgOption, nil
}
-func userSessionExpired() (bool, error) {
+func UserSessionExpired() (bool, error) {
sessionExpiresAtString, err := GetAuthField(SESSION_EXPIRES_AT_UNIX)
if err != nil {
return false, fmt.Errorf("get %s: %w", SESSION_EXPIRES_AT_UNIX, err)
@@ -87,7 +97,7 @@ func userSessionExpired() (bool, error) {
return now.After(sessionExpiresAt), nil
}
-func getAccessToken() (string, error) {
+func GetAccessToken() (string, error) {
accessToken, err := GetAuthField(ACCESS_TOKEN)
if err != nil {
return "", fmt.Errorf("get %s: %w", ACCESS_TOKEN, err)
@@ -100,15 +110,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
@@ -123,3 +141,78 @@ 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
+}
diff --git a/internal/pkg/auth/auth_test.go b/internal/pkg/auth/auth_test.go
index 9d4ee8778..f7355f365 100644
--- a/internal/pkg/auth/auth_test.go
+++ b/internal/pkg/auth/auth_test.go
@@ -70,7 +70,6 @@ func TestAuthenticationConfig(t *testing.T) {
saKey string
privateKeySet bool
tokenEndpoint string
- jwksEndpoint string
isValid bool
expectedCustomAuthSet bool
expectedTokenSet bool
@@ -102,7 +101,6 @@ func TestAuthenticationConfig(t *testing.T) {
saKey: testServiceAccountKey,
privateKeySet: true,
tokenEndpoint: "token_url",
- jwksEndpoint: "jwks_url",
isValid: true,
expectedCustomAuthSet: true,
},
@@ -115,7 +113,6 @@ func TestAuthenticationConfig(t *testing.T) {
saKey: testServiceAccountKey,
privateKeySet: true,
tokenEndpoint: "token_url",
- jwksEndpoint: "jwks_url",
isValid: false,
},
{
@@ -180,7 +177,6 @@ func TestAuthenticationConfig(t *testing.T) {
authFields[REFRESH_TOKEN] = tt.refreshToken
authFields[SERVICE_ACCOUNT_KEY] = tt.saKey
authFields[TOKEN_CUSTOM_ENDPOINT] = tt.tokenEndpoint
- authFields[JWKS_CUSTOM_ENDPOINT] = tt.jwksEndpoint
err = SetAuthFlow(tt.flow)
if err != nil {
@@ -192,7 +188,7 @@ func TestAuthenticationConfig(t *testing.T) {
}
reauthorizeUserCalled := false
- reauthenticateUser := func(p *print.Printer, isReauthentication bool) error {
+ reauthenticateUser := func(_ *print.Printer, _ bool) error {
if reauthorizeUserCalled {
t.Errorf("user reauthorized more than once")
}
@@ -245,7 +241,6 @@ func TestInitKeyFlow(t *testing.T) {
saKey string
privateKeySet bool
tokenEndpoint string
- jwksEndpoint string
isValid bool
}{
{
@@ -255,7 +250,6 @@ func TestInitKeyFlow(t *testing.T) {
saKey: testServiceAccountKey,
privateKeySet: true,
tokenEndpoint: "token_url",
- jwksEndpoint: "jwks_url",
isValid: true,
},
{
@@ -265,7 +259,6 @@ func TestInitKeyFlow(t *testing.T) {
saKey: "",
privateKeySet: true,
tokenEndpoint: "token_url",
- jwksEndpoint: "jwks_url",
isValid: false,
},
{
@@ -275,7 +268,6 @@ func TestInitKeyFlow(t *testing.T) {
saKey: testServiceAccountKey,
privateKeySet: false,
tokenEndpoint: "token_url",
- jwksEndpoint: "jwks_url",
isValid: false,
},
{
@@ -285,7 +277,6 @@ func TestInitKeyFlow(t *testing.T) {
saKey: testServiceAccountKey,
privateKeySet: true,
tokenEndpoint: "token_url",
- jwksEndpoint: "jwks_url",
isValid: false,
},
{
@@ -295,7 +286,6 @@ func TestInitKeyFlow(t *testing.T) {
saKey: testServiceAccountKey,
privateKeySet: true,
tokenEndpoint: "token_url",
- jwksEndpoint: "jwks_url",
isValid: false,
},
}
@@ -326,7 +316,6 @@ func TestInitKeyFlow(t *testing.T) {
authFields[REFRESH_TOKEN] = tt.refreshToken
authFields[SERVICE_ACCOUNT_KEY] = tt.saKey
authFields[TOKEN_CUSTOM_ENDPOINT] = tt.tokenEndpoint
- authFields[JWKS_CUSTOM_ENDPOINT] = tt.jwksEndpoint
err = SetAuthFieldMap(authFields)
if err != nil {
t.Fatalf("Failed to set in auth storage: %v", err)
diff --git a/internal/pkg/auth/service_account.go b/internal/pkg/auth/service_account.go
index 747832f6e..1f1b01729 100644
--- a/internal/pkg/auth/service_account.go
+++ b/internal/pkg/auth/service_account.go
@@ -35,7 +35,8 @@ var _ http.RoundTripper = &keyFlowWithStorage{}
// For the key flow, it fetches an access and refresh token from the Service Account API.
// For the token flow, it just stores the provided token and doesn't check if it is valid.
// It returns the email associated with the service account
-func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper) (email string, err error) {
+// If disableWriting is set to true the credentials are not stored on disk (keyring, file).
+func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper, disableWriting bool) (email, accessToken string, err error) {
authFields := make(map[authFieldKey]string)
var authFlowType AuthFlow
switch flow := rt.(type) {
@@ -46,12 +47,12 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper) (email s
accessToken, err := flow.GetAccessToken()
if err != nil {
p.Debug(print.ErrorLevel, "get access token: %v", err)
- return "", &errors.ActivateServiceAccountError{}
+ return "", "", &errors.ActivateServiceAccountError{}
}
serviceAccountKey := flow.GetConfig().ServiceAccountKey
saKeyBytes, err := json.Marshal(serviceAccountKey)
if err != nil {
- return "", fmt.Errorf("marshal service account key: %w", err)
+ return "", "", fmt.Errorf("marshal service account key: %w", err)
}
authFields[ACCESS_TOKEN] = accessToken
@@ -64,12 +65,12 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper) (email s
authFields[ACCESS_TOKEN] = flow.GetConfig().ServiceAccountToken
default:
- return "", fmt.Errorf("could not authenticate using any of the supported authentication flows (key and token): please report this issue")
+ return "", "", fmt.Errorf("could not authenticate using any of the supported authentication flows (key and token): please report this issue")
}
email, err = getEmailFromToken(authFields[ACCESS_TOKEN])
if err != nil {
- return "", fmt.Errorf("get email from access token: %w", err)
+ return "", "", fmt.Errorf("get email from access token: %w", err)
}
p.Debug(print.DebugLevel, "successfully authenticated service account %s", email)
@@ -78,20 +79,22 @@ func AuthenticateServiceAccount(p *print.Printer, rt http.RoundTripper) (email s
sessionExpiresAtUnix, err := getStartingSessionExpiresAtUnix()
if err != nil {
- return "", fmt.Errorf("compute session expiration timestamp: %w", err)
+ return "", "", fmt.Errorf("compute session expiration timestamp: %w", err)
}
authFields[SESSION_EXPIRES_AT_UNIX] = sessionExpiresAtUnix
- err = SetAuthFlow(authFlowType)
- if err != nil {
- return "", fmt.Errorf("set auth flow type: %w", err)
- }
- err = SetAuthFieldMap(authFields)
- if err != nil {
- return "", fmt.Errorf("set in auth storage: %w", err)
+ if !disableWriting {
+ err = SetAuthFlow(authFlowType)
+ if err != nil {
+ return "", "", fmt.Errorf("set auth flow type: %w", err)
+ }
+ err = SetAuthFieldMap(authFields)
+ if err != nil {
+ return "", "", fmt.Errorf("set in auth storage: %w", err)
+ }
}
- return authFields[SERVICE_ACCOUNT_EMAIL], nil
+ return authFields[SERVICE_ACCOUNT_EMAIL], authFields[ACCESS_TOKEN], nil
}
// initKeyFlowWithStorage initializes the keyFlow from the SDK and creates a keyFlowWithStorage struct that uses that keyFlow
@@ -102,7 +105,6 @@ func initKeyFlowWithStorage() (*keyFlowWithStorage, error) {
SERVICE_ACCOUNT_KEY: "",
PRIVATE_KEY: "",
TOKEN_CUSTOM_ENDPOINT: "",
- JWKS_CUSTOM_ENDPOINT: "",
}
err := GetAuthFieldMap(authFields)
if err != nil {
diff --git a/internal/pkg/auth/service_account_test.go b/internal/pkg/auth/service_account_test.go
index a4b3a72ce..adc0f8bc5 100644
--- a/internal/pkg/auth/service_account_test.go
+++ b/internal/pkg/auth/service_account_test.go
@@ -153,7 +153,7 @@ func TestAuthenticateServiceAccount(t *testing.T) {
}
p := print.NewPrinter()
- email, err := AuthenticateServiceAccount(p, flow)
+ email, _, err := AuthenticateServiceAccount(p, flow, false)
if !tt.isValid {
if err == nil {
diff --git a/internal/pkg/auth/storage.go b/internal/pkg/auth/storage.go
index d44cc1133..686a0f677 100644
--- a/internal/pkg/auth/storage.go
+++ b/internal/pkg/auth/storage.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
-
"os"
"path/filepath"
@@ -23,21 +22,23 @@ type AuthFlow string
const (
keyringService = "stackit-cli"
- textFileFolderName = "stackit"
textFileName = "cli-auth-storage.txt"
+ envAccessTokenName = "STACKIT_ACCESS_TOKEN"
)
const (
- SESSION_EXPIRES_AT_UNIX authFieldKey = "session_expires_at_unix"
- ACCESS_TOKEN authFieldKey = "access_token"
- REFRESH_TOKEN authFieldKey = "refresh_token"
- SERVICE_ACCOUNT_TOKEN authFieldKey = "service_account_token"
- SERVICE_ACCOUNT_EMAIL authFieldKey = "service_account_email"
- USER_EMAIL authFieldKey = "user_email"
- SERVICE_ACCOUNT_KEY authFieldKey = "service_account_key"
- PRIVATE_KEY authFieldKey = "private_key"
- TOKEN_CUSTOM_ENDPOINT authFieldKey = "token_custom_endpoint"
- JWKS_CUSTOM_ENDPOINT authFieldKey = "jwks_custom_endpoint"
+ 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 (
@@ -58,8 +59,19 @@ var authFieldKeys = []authFieldKey{
SERVICE_ACCOUNT_KEY,
PRIVATE_KEY,
TOKEN_CUSTOM_ENDPOINT,
- JWKS_CUSTOM_ENDPOINT,
+ IDP_TOKEN_ENDPOINT,
authFlowType,
+ CACHE_ENCRYPTION_KEY,
+ CACHE_ENCRYPTION_KEY_AGE,
+}
+
+// All fields that are set when a user logs in
+// These fields should match the ones in LoginUser, which is ensured by the tests
+var loginAuthFieldKeys = []authFieldKey{
+ SESSION_EXPIRES_AT_UNIX,
+ ACCESS_TOKEN,
+ REFRESH_TOKEN,
+ USER_EMAIL,
}
func SetAuthFlow(value AuthFlow) error {
@@ -105,6 +117,65 @@ func setAuthFieldInKeyring(activeProfile string, key authFieldKey, value string)
return keyring.Set(keyringService, string(key), value)
}
+func DeleteAuthField(key authFieldKey) error {
+ activeProfile, err := config.GetProfile()
+ if err != nil {
+ return fmt.Errorf("get profile: %w", err)
+ }
+ return deleteAuthFieldWithProfile(activeProfile, key)
+}
+
+func deleteAuthFieldWithProfile(profile string, key authFieldKey) error {
+ err := deleteAuthFieldInKeyring(profile, key)
+ if err != nil {
+ // if the key is not found, we can ignore the error
+ if !errors.Is(err, keyring.ErrNotFound) {
+ errFallback := deleteAuthFieldInEncodedTextFile(profile, key)
+ if errFallback != nil {
+ return fmt.Errorf("delete from keyring failed (%w), try deleting from encoded text file: %w", err, errFallback)
+ }
+ }
+ }
+ return nil
+}
+
+func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) error {
+ err := createEncodedTextFile(activeProfile)
+ if err != nil {
+ return err
+ }
+
+ textFileDir := config.GetProfileFolderPath(activeProfile)
+ textFilePath := filepath.Join(textFileDir, textFileName)
+
+ contentEncoded, err := os.ReadFile(textFilePath)
+ if err != nil {
+ return fmt.Errorf("read file: %w", err)
+ }
+ contentBytes, err := base64.StdEncoding.DecodeString(string(contentEncoded))
+ if err != nil {
+ return fmt.Errorf("decode file: %w", err)
+ }
+ content := map[authFieldKey]string{}
+ err = json.Unmarshal(contentBytes, &content)
+ if err != nil {
+ return fmt.Errorf("unmarshal file: %w", err)
+ }
+
+ delete(content, key)
+
+ contentBytes, err = json.Marshal(content)
+ if err != nil {
+ return fmt.Errorf("marshal file: %w", err)
+ }
+ contentEncoded = []byte(base64.StdEncoding.EncodeToString(contentBytes))
+ err = os.WriteFile(textFilePath, contentEncoded, 0o600)
+ if err != nil {
+ return fmt.Errorf("write file: %w", err)
+ }
+ return nil
+}
+
func deleteAuthFieldInKeyring(activeProfile string, key authFieldKey) error {
keyringServiceLocal := keyringService
if activeProfile != config.DefaultProfileName {
@@ -231,7 +302,7 @@ func createEncodedTextFile(activeProfile string) error {
textFileDir := config.GetProfileFolderPath(activeProfile)
textFilePath := filepath.Join(textFileDir, textFileName)
- err := os.MkdirAll(textFileDir, os.ModePerm)
+ err := os.MkdirAll(textFileDir, 0o750)
if err != nil {
return fmt.Errorf("create file dir: %w", err)
}
@@ -273,7 +344,55 @@ func GetProfileEmail(profile string) string {
return email
}
-func DeleteProfileFromKeyring(profile string) error {
+// GetAuthEmail returns the email of the authenticated account.
+// If the environment variable STACKIT_ACCESS_TOKEN is set, the email of this token will be returned.
+func GetAuthEmail() (string, error) {
+ // If STACKIT_ACCESS_TOKEN is set, get the mail from the token
+ if accessToken := os.Getenv(envAccessTokenName); accessToken != "" {
+ email, err := getEmailFromToken(accessToken)
+ if err != nil {
+ return "", fmt.Errorf("error getting email from token: %w", err)
+ }
+ return email, nil
+ }
+
+ profile, err := config.GetProfile()
+ if err != nil {
+ return "", fmt.Errorf("error getting profile: %w", err)
+ }
+ email := GetProfileEmail(profile)
+ if email == "" {
+ return "", fmt.Errorf("error getting profile email. email is empty")
+ }
+ return email, nil
+}
+
+func LoginUser(email, accessToken, refreshToken, sessionExpiresAtUnix string) error {
+ authFields := map[authFieldKey]string{
+ SESSION_EXPIRES_AT_UNIX: sessionExpiresAtUnix,
+ ACCESS_TOKEN: accessToken,
+ REFRESH_TOKEN: refreshToken,
+ USER_EMAIL: email,
+ }
+
+ err := SetAuthFieldMap(authFields)
+ if err != nil {
+ return fmt.Errorf("set auth fields: %w", err)
+ }
+ return nil
+}
+
+func LogoutUser() error {
+ for _, key := range loginAuthFieldKeys {
+ err := DeleteAuthField(key)
+ if err != nil {
+ return fmt.Errorf("delete auth field \"%s\": %w", key, err)
+ }
+ }
+ return nil
+}
+
+func DeleteProfileAuth(profile string) error {
err := config.ValidateProfile(profile)
if err != nil {
return fmt.Errorf("validate profile: %w", err)
@@ -284,12 +403,9 @@ func DeleteProfileFromKeyring(profile string) error {
}
for _, key := range authFieldKeys {
- err := deleteAuthFieldInKeyring(profile, key)
+ err := deleteAuthFieldWithProfile(profile, key)
if err != nil {
- // if the key is not found, we can ignore the error
- if !errors.Is(err, keyring.ErrNotFound) {
- return fmt.Errorf("delete auth field \"%s\" from keyring: %w", key, err)
- }
+ return fmt.Errorf("delete auth field \"%s\": %w", key, err)
}
}
diff --git a/internal/pkg/auth/storage_test.go b/internal/pkg/auth/storage_test.go
index 1591a024f..37eeee33e 100644
--- a/internal/pkg/auth/storage_test.go
+++ b/internal/pkg/auth/storage_test.go
@@ -1,17 +1,13 @@
package auth
import (
- "encoding/base64"
- "encoding/json"
"fmt"
"os"
- "path/filepath"
"testing"
"time"
- "github.com/zalando/go-keyring"
-
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
+ "github.com/zalando/go-keyring"
)
func TestSetGetAuthField(t *testing.T) {
@@ -317,7 +313,7 @@ func TestSetGetAuthFieldWithProfile(t *testing.T) {
}
}
- err = deleteAuthFieldProfile(tt.activeProfile)
+ err = deleteProfileFiles(tt.activeProfile)
if err != nil {
t.Errorf("Post-test cleanup failed: remove profile \"%s\": %v. Please remove it manually", tt.activeProfile, err)
}
@@ -468,6 +464,156 @@ func TestSetGetAuthFieldKeyring(t *testing.T) {
}
}
+func TestDeleteAuthField(t *testing.T) {
+ tests := []struct {
+ description string
+ keyringFails bool
+ key authFieldKey
+ noKey bool
+ }{
+ {
+ description: "base",
+ key: "test-field-1",
+ },
+ {
+ description: "key doesnt exist",
+ key: "doesnt-exist",
+ noKey: true,
+ },
+ {
+ description: "keyring fails",
+ keyringFails: true,
+ key: "test-field-1",
+ },
+ {
+ description: "keyring fails, no key exists",
+ keyringFails: true,
+ noKey: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ if !tt.keyringFails {
+ keyring.MockInit()
+ } else {
+ keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing"))
+ }
+
+ // Append random string to auth field key and value to avoid conflicts
+ testField1 := authFieldKey(fmt.Sprintf("test-field-1-%s", time.Now().Format(time.RFC3339)))
+ testValue1 := fmt.Sprintf("value-1-%s", time.Now().Format(time.RFC3339))
+
+ if !tt.noKey {
+ err := SetAuthField(testField1, testValue1)
+ if err != nil {
+ t.Fatalf("Failed to set \"%s\" as \"%s\": %v", testField1, testValue1, err)
+ }
+ }
+
+ err := DeleteAuthField(tt.key)
+ if err != nil {
+ t.Fatalf("Failed to delete field \"%s\": %v", tt.key, err)
+ }
+
+ // Check if key still exists
+ _, err = GetAuthField(tt.key)
+ if err == nil {
+ t.Fatalf("Key \"%s\" still exists after deletion", tt.key)
+ }
+ })
+ }
+}
+
+func TestDeleteAuthFieldWithProfile(t *testing.T) {
+ tests := []struct {
+ description string
+ keyringFails bool
+ profile string
+ key authFieldKey
+ noKey bool
+ }{
+ {
+ description: "base",
+ profile: "default",
+ key: "test-field-1",
+ },
+ {
+ description: "key doesnt exist",
+ profile: "default",
+ key: "doesnt-exist",
+ noKey: true,
+ },
+ {
+ description: "keyring fails",
+ profile: "default",
+ keyringFails: true,
+ key: "test-field-1",
+ },
+ {
+ description: "keyring fails, no key exists",
+ profile: "default",
+ keyringFails: true,
+ noKey: true,
+ },
+ {
+ description: "base, custom profile",
+ profile: "test-profile",
+ key: "test-field-1",
+ },
+ {
+ description: "key doesnt exist, custom profile",
+ profile: "test-profile",
+ key: "doesnt-exist",
+ noKey: true,
+ },
+ {
+ description: "keyring fails, custom profile",
+ profile: "test-profile",
+ keyringFails: true,
+ key: "test-field-1",
+ },
+ {
+ description: "keyring fails, no key exists, custom profile",
+ profile: "test-profile",
+ keyringFails: true,
+ noKey: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ if !tt.keyringFails {
+ keyring.MockInit()
+ } else {
+ keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing"))
+ }
+
+ // Append random string to auth field key and value to avoid conflicts
+ testField1 := authFieldKey(fmt.Sprintf("test-field-1-%s", time.Now().Format(time.RFC3339)))
+ testValue1 := fmt.Sprintf("value-1-%s", time.Now().Format(time.RFC3339))
+
+ if !tt.noKey {
+ err := SetAuthField(testField1, testValue1)
+ if err != nil {
+ t.Fatalf("Failed to set \"%s\" as \"%s\": %v", testField1, testValue1, err)
+ }
+ }
+
+ err := deleteAuthFieldWithProfile(tt.profile, tt.key)
+ if err != nil {
+ t.Fatalf("Failed to delete field \"%s\": %v", tt.key, err)
+ }
+
+ // Check if key still exists
+ _, err = GetAuthField(tt.key)
+ if err == nil {
+ t.Fatalf("Key \"%s\" still exists after deletion", tt.key)
+ }
+ })
+ }
+}
+
func TestDeleteAuthFieldKeyring(t *testing.T) {
tests := []struct {
description string
@@ -606,7 +752,7 @@ func TestDeleteProfileFromKeyring(t *testing.T) {
}
}
- err := DeleteProfileFromKeyring(tt.activeProfile)
+ err := DeleteProfileAuth(tt.activeProfile)
if err != nil {
if tt.isValid {
t.Fatalf("Failed to delete profile \"%s\" from keyring: %v", tt.activeProfile, err)
@@ -767,7 +913,7 @@ func TestSetGetAuthFieldEncodedTextFile(t *testing.T) {
}
}
- err = deleteAuthFieldProfile(tt.activeProfile)
+ err = deleteProfileFiles(tt.activeProfile)
if err != nil {
t.Errorf("Post-test cleanup failed: remove profile \"%s\": %v. Please remove it manually", tt.activeProfile, err)
}
@@ -925,7 +1071,7 @@ func TestGetProfileEmail(t *testing.T) {
t.Fatalf("Failed to remove service account email: %v", err)
}
- err = deleteAuthFieldProfile(tt.activeProfile)
+ err = deleteProfileFiles(tt.activeProfile)
if err != nil {
t.Fatalf("Failed to remove profile: %v", err)
}
@@ -933,44 +1079,7 @@ func TestGetProfileEmail(t *testing.T) {
}
}
-func deleteAuthFieldInEncodedTextFile(activeProfile string, key authFieldKey) error {
- err := createEncodedTextFile(activeProfile)
- if err != nil {
- return err
- }
-
- textFileDir := config.GetProfileFolderPath(activeProfile)
- textFilePath := filepath.Join(textFileDir, textFileName)
-
- contentEncoded, err := os.ReadFile(textFilePath)
- if err != nil {
- return fmt.Errorf("read file: %w", err)
- }
- contentBytes, err := base64.StdEncoding.DecodeString(string(contentEncoded))
- if err != nil {
- return fmt.Errorf("decode file: %w", err)
- }
- content := map[authFieldKey]string{}
- err = json.Unmarshal(contentBytes, &content)
- if err != nil {
- return fmt.Errorf("unmarshal file: %w", err)
- }
-
- delete(content, key)
-
- contentBytes, err = json.Marshal(content)
- if err != nil {
- return fmt.Errorf("marshal file: %w", err)
- }
- contentEncoded = []byte(base64.StdEncoding.EncodeToString(contentBytes))
- err = os.WriteFile(textFilePath, contentEncoded, 0o600)
- if err != nil {
- return fmt.Errorf("write file: %w", err)
- }
- return nil
-}
-
-func deleteAuthFieldProfile(activeProfile string) error {
+func deleteProfileFiles(activeProfile string) error {
if activeProfile == config.DefaultProfileName {
// Do not delete the default profile
return nil
@@ -990,3 +1099,119 @@ func makeProfileNameUnique(profile string) string {
}
return fmt.Sprintf("%s-%s", profile, time.Now().Format("20060102150405"))
}
+
+func TestAuthorizeDeauthorizeUserProfileAuth(t *testing.T) {
+ type args struct {
+ sessionExpiresAtUnix string
+ accessToken string
+ refreshToken string
+ email string
+ }
+ tests := []struct {
+ name string
+ args args
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ sessionExpiresAtUnix: "1234567890",
+ accessToken: "accessToken",
+ refreshToken: "refreshToken",
+ email: "test@example.com",
+ },
+ wantErr: false,
+ },
+ {
+ name: "no email",
+ args: args{
+ sessionExpiresAtUnix: "1234567890",
+ accessToken: "accessToken",
+ refreshToken: "refreshToken",
+ email: "",
+ },
+ wantErr: false,
+ },
+ {
+ name: "no session expires",
+ args: args{
+ sessionExpiresAtUnix: "",
+ accessToken: "accessToken",
+ refreshToken: "refreshToken",
+ email: "test@example.com",
+ },
+ wantErr: false,
+ },
+ {
+ name: "no access token",
+ args: args{
+ sessionExpiresAtUnix: "1234567890",
+ accessToken: "",
+ refreshToken: "refreshToken",
+ email: "test@example.com",
+ },
+ wantErr: false,
+ },
+ {
+ name: "no refresh token",
+ args: args{
+ sessionExpiresAtUnix: "1234567890",
+ accessToken: "accessToken",
+ refreshToken: "",
+ email: "test@example.com",
+ },
+ wantErr: false,
+ },
+ {
+ name: "all empty args",
+ args: args{
+ sessionExpiresAtUnix: "",
+ accessToken: "",
+ refreshToken: "",
+ email: "",
+ },
+ wantErr: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ keyring.MockInit()
+
+ if err := LoginUser(tt.args.email, tt.args.accessToken, tt.args.refreshToken, tt.args.sessionExpiresAtUnix); (err != nil) != tt.wantErr {
+ t.Errorf("AuthorizeUserProfileAuth() error = %v, wantErr %v", err, tt.wantErr)
+ }
+
+ // Test values
+ testLoginAuthFields := []string{
+ tt.args.sessionExpiresAtUnix,
+ tt.args.accessToken,
+ tt.args.refreshToken,
+ tt.args.email,
+ }
+
+ // Check if the fields are set
+ for i := range loginAuthFieldKeys {
+ gotKey, err := GetAuthField(loginAuthFieldKeys[i])
+ if err != nil {
+ t.Errorf("Field \"%s\" not set after authorization", loginAuthFieldKeys[i])
+ }
+ expectedKey := testLoginAuthFields[i]
+ if gotKey != expectedKey {
+ t.Errorf("Field \"%s\" is wrong: expected \"%s\", got \"%s\"", loginAuthFieldKeys[i], expectedKey, gotKey)
+ }
+ }
+
+ if err := LogoutUser(); err != nil {
+ t.Errorf("DeauthorizeUserProfileAuth() error = %v", err)
+ }
+
+ // Check if the fields are deleted
+ for _, key := range loginAuthFieldKeys {
+ _, err := GetAuthField(key)
+ if err == nil {
+ t.Errorf("Field \"%s\" still exists after deauthorization", key)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/pkg/auth/templates/login-successful.html b/internal/pkg/auth/templates/login-successful.html
index 45cf2721c..3e2d0a5ba 100644
--- a/internal/pkg/auth/templates/login-successful.html
+++ b/internal/pkg/auth/templates/login-successful.html
@@ -6,7 +6,7 @@
@@ -271,7 +271,7 @@
diff --git a/internal/pkg/auth/templates/stackit_nav_logo_light.svg b/internal/pkg/auth/templates/stackit_nav_logo_light.svg
new file mode 100644
index 000000000..5da793e0b
--- /dev/null
+++ b/internal/pkg/auth/templates/stackit_nav_logo_light.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/internal/pkg/auth/user_login.go b/internal/pkg/auth/user_login.go
index 577a47846..054c74c89 100644
--- a/internal/pkg/auth/user_login.go
+++ b/internal/pkg/auth/user_login.go
@@ -1,7 +1,7 @@
package auth
import (
- "embed"
+ _ "embed"
"encoding/json"
"errors"
"fmt"
@@ -11,35 +11,77 @@ import (
"net/http"
"os"
"os/exec"
- "path"
"runtime"
"strconv"
"strings"
"time"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"golang.org/x/oauth2"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
)
const (
- authDomain = "auth.01.idp.eu01.stackit.cloud/oauth"
- clientId = "stackit-cli-client-id"
- loginSuccessPath = "/login-successful"
- stackitLandingPage = "https://www.stackit.de"
- htmlTemplatesPath = "templates"
- loginSuccessfulHTMLFile = "login-successful.html"
+ defaultWellKnownConfig = "https://accounts.stackit.cloud/.well-known/openid-configuration"
+ defaultCLIClientID = "stackit-cli-0000-0000-000000000001"
+
+ loginSuccessPath = "/login-successful"
+
+ // The IDP doesn't support wildcards for the port,
+ // so we configure a range of ports from 8000 to 8020
+ defaultPort = 8000
+ configuredPortRange = 20
)
-//go:embed templates/*
-var htmlContent embed.FS
+//go:embed templates/login-successful.html
+var htmlTemplateContent string
-type User struct {
+//go:embed templates/stackit_nav_logo_light.svg
+var logoSvgContent []byte
+
+type InputValues struct {
Email string
+ Logo string
+}
+
+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)
+ if err != nil {
+ return fmt.Errorf("parse IDP well-known configuration: %w", err)
+ }
+
+ idpClientID, err := getIDPClientID()
+ if err != nil {
+ return err
+ }
+ if idpClientID != defaultCLIClientID {
+ p.Warn("You are using a custom client ID (%s) for authentication.\n", idpClientID)
+ err := p.PromptForEnter("Press Enter to proceed with the login...")
+ if err != nil {
+ return err
+ }
+ }
+
if isReauthentication {
err := p.PromptForEnter("Your session has expired, press Enter to login again...")
if err != nil {
@@ -47,30 +89,45 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error {
}
}
- listener, err := net.Listen("tcp", ":0")
- if err != nil {
- return fmt.Errorf("bind port for login redirect: %w", err)
+ var redirectURL string
+ var listener net.Listener
+ var listenerErr error
+ var port int
+ for i := range configuredPortRange {
+ port = defaultPort + i
+ portString := fmt.Sprintf(":%s", strconv.Itoa(port))
+ p.Debug(print.DebugLevel, "trying to bind port %d for login redirect", port)
+ listener, listenerErr = net.Listen("tcp", portString)
+ if listenerErr == nil {
+ 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)
}
- address, ok := listener.Addr().(*net.TCPAddr)
- if !ok {
- return fmt.Errorf("assert listener address type to TCP address")
+ if listenerErr != nil {
+ return fmt.Errorf("unable to bind port for login redirect, tried from port %d to %d: %w", defaultPort, port, err)
}
- redirectURL := fmt.Sprintf("http://localhost:%d", address.Port)
conf := &oauth2.Config{
- ClientID: clientId,
+ ClientID: idpClientID,
Endpoint: oauth2.Endpoint{
- AuthURL: fmt.Sprintf("https://%s/authorize", authDomain),
+ AuthURL: idpWellKnownConfig.AuthorizationEndpoint,
},
- Scopes: []string{"openid"},
+ Scopes: []string{"openid offline_access email"},
RedirectURL: redirectURL,
}
// 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()
@@ -85,7 +142,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error {
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
p.Debug(print.DebugLevel, "received request from authentication server")
// Close the server only if there was an error
- // Otherwise, it will redirect to the succesfull login page
+ // Otherwise, it will redirect to the successful login page
defer func() {
if errServer != nil {
fmt.Println(errServer)
@@ -95,15 +152,19 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error {
// Get the authorization code
code := r.URL.Query().Get("code")
+ errDescription := r.URL.Query().Get("error_description")
if code == "" {
errServer = fmt.Errorf("could not find 'code' URL parameter")
+ if errDescription != "" {
+ errServer = fmt.Errorf("%w: %s", errServer, errDescription)
+ }
return
}
p.Debug(print.DebugLevel, "trading authorization code for access and refresh tokens")
// Trade the authorization code and the code verifier for access and refresh tokens
- accessToken, refreshToken, err := getUserAccessAndRefreshTokens(authDomain, clientId, codeVerifier, code, redirectURL)
+ accessToken, refreshToken, err := getUserAccessAndRefreshTokens(idpWellKnownConfig, idpClientID, codeVerifier, code, redirectURL)
if err != nil {
errServer = fmt.Errorf("retrieve tokens: %w", err)
return
@@ -139,13 +200,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error {
p.Debug(print.DebugLevel, "user %s logged in successfully", email)
- authFields := map[authFieldKey]string{
- SESSION_EXPIRES_AT_UNIX: sessionExpiresAtUnix,
- ACCESS_TOKEN: accessToken,
- REFRESH_TOKEN: refreshToken,
- USER_EMAIL: email,
- }
- err = SetAuthFieldMap(authFields)
+ err = LoginUser(email, accessToken, refreshToken, sessionExpiresAtUnix)
if err != nil {
errServer = fmt.Errorf("set in auth storage: %w", err)
return
@@ -158,7 +213,7 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error {
http.Redirect(w, r, loginSuccessURL, http.StatusSeeOther)
})
- mux.HandleFunc(loginSuccessPath, func(w http.ResponseWriter, r *http.Request) {
+ mux.HandleFunc(loginSuccessPath, func(w http.ResponseWriter, _ *http.Request) {
defer cleanup(server)
email, err := GetAuthField(USER_EMAIL)
@@ -166,25 +221,27 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error {
errServer = fmt.Errorf("read user email: %w", err)
}
- user := User{
+ input := InputValues{
Email: email,
+ Logo: utils.Base64Encode(logoSvgContent),
}
// ParseFS expects paths using forward slashes, even on Windows
// See: https://github.com/golang/go/issues/44305#issuecomment-780111748
- htmlTemplate, err := template.ParseFS(htmlContent, path.Join(htmlTemplatesPath, loginSuccessfulHTMLFile))
+ htmlTemplate, err := template.New("loginSuccess").Parse(htmlTemplateContent)
if err != nil {
errServer = fmt.Errorf("parse html file: %w", err)
}
- err = htmlTemplate.Execute(w, user)
+ err = htmlTemplate.Execute(w, input)
if err != nil {
errServer = fmt.Errorf("render page: %w", err)
}
})
- p.Debug(print.DebugLevel, "opening browser for authentication")
- p.Debug(print.DebugLevel, "using authentication server on %s", authDomain)
+ p.Debug(print.DebugLevel, "opening browser for authentication: %s", authorizationURL)
+ p.Debug(print.DebugLevel, "using authentication server on %s", idpWellKnownConfig.Issuer)
+ p.Debug(print.DebugLevel, "using client ID %s for authentication ", idpClientID)
// Open a browser window to the authorizationURL
err = openBrowser(authorizationURL)
@@ -192,6 +249,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)
@@ -209,9 +270,8 @@ func AuthorizeUser(p *print.Printer, isReauthentication bool) error {
}
// getUserAccessAndRefreshTokens trades the authorization code retrieved from the first OAuth2 leg for an access token and a refresh token
-func getUserAccessAndRefreshTokens(authDomain, clientID, codeVerifier, authorizationCode, callbackURL string) (accessToken, refreshToken string, err error) {
- // Set the authUrl and form-encoded data for the POST to the access token endpoint
- authUrl := fmt.Sprintf("https://%s/token", authDomain)
+func getUserAccessAndRefreshTokens(idpWellKnownConfig *wellKnownConfig, clientID, codeVerifier, authorizationCode, callbackURL string) (accessToken, refreshToken string, err error) {
+ // Set form-encoded data for the POST to the access token endpoint
data := fmt.Sprintf(
"grant_type=authorization_code&client_id=%s"+
"&code_verifier=%s"+
@@ -221,7 +281,7 @@ func getUserAccessAndRefreshTokens(authDomain, clientID, codeVerifier, authoriza
payload := strings.NewReader(data)
// Create the request and execute it
- req, _ := http.NewRequest("POST", authUrl, payload)
+ req, _ := http.NewRequest("POST", idpWellKnownConfig.TokenEndpoint, payload)
req.Header.Add("content-type", "application/x-www-form-urlencoded")
httpClient := &http.Client{}
res, err := httpClient.Do(req)
@@ -292,3 +352,48 @@ 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
new file mode 100644
index 000000000..7b61a4af5
--- /dev/null
+++ b/internal/pkg/auth/user_login_test.go
@@ -0,0 +1,110 @@
+package auth
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/zalando/go-keyring"
+)
+
+type apiClientMocked struct {
+ getFails bool
+ getResponse string
+}
+
+func (a *apiClientMocked) Do(_ *http.Request) (*http.Response, error) {
+ if a.getFails {
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ }, fmt.Errorf("not found")
+ }
+ return &http.Response{
+ Status: "200 OK",
+ StatusCode: http.StatusAccepted,
+ 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 d3e3a5a1d..cdb852f77 100644
--- a/internal/pkg/auth/user_token_flow.go
+++ b/internal/pkg/auth/user_token_flow.go
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/url"
+ "strings"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -19,6 +20,7 @@ type userTokenFlow struct {
authFlow AuthFlow
accessToken string
refreshToken string
+ tokenEndpoint string
}
// Ensure the implementation satisfies the expected interface
@@ -43,19 +45,18 @@ func (utf *userTokenFlow) RoundTrip(req *http.Request) (*http.Response, error) {
}
accessTokenValid := false
- if accessTokenExpired, err := tokenExpired(utf.accessToken); err != nil {
+ accessTokenExpired, err := TokenExpired(utf.accessToken)
+ if err != nil {
return nil, fmt.Errorf("check if access token has expired: %w", err)
} else if !accessTokenExpired {
accessTokenValid = true
- } else if refreshTokenExpired, err := tokenExpired(utf.refreshToken); err != nil {
- return nil, fmt.Errorf("check if refresh token has expired: %w", err)
- } else if !refreshTokenExpired {
+ } else {
utf.printer.Debug(print.DebugLevel, "access token expired, refreshing...")
err = refreshTokens(utf)
if err == nil {
accessTokenValid = true
} else {
- utf.printer.Debug(print.ErrorLevel, "refresh access token: %v", err)
+ utf.printer.Debug(print.ErrorLevel, "refresh access token: %w", err)
}
}
@@ -77,8 +78,9 @@ func loadVarsFromStorage(utf *userTokenFlow) error {
return fmt.Errorf("get auth flow type: %w", err)
}
authFields := map[authFieldKey]string{
- ACCESS_TOKEN: "",
- REFRESH_TOKEN: "",
+ ACCESS_TOKEN: "",
+ REFRESH_TOKEN: "",
+ IDP_TOKEN_ENDPOINT: "",
}
err = GetAuthFieldMap(authFields)
if err != nil {
@@ -88,6 +90,7 @@ func loadVarsFromStorage(utf *userTokenFlow) error {
utf.authFlow = authFlow
utf.accessToken = authFields[ACCESS_TOKEN]
utf.refreshToken = authFields[REFRESH_TOKEN]
+ utf.tokenEndpoint = authFields[IDP_TOKEN_ENDPOINT]
return nil
}
@@ -106,7 +109,7 @@ func reauthenticateUser(utf *userTokenFlow) error {
return nil
}
-func tokenExpired(token string) (bool, error) {
+func TokenExpired(token string) (bool, error) {
// We can safely use ParseUnverified because we are not authenticating the user at this point.
// We're just checking the expiration time
tokenParsed, _, err := jwt.NewParser().ParseUnverified(token, &jwt.RegisteredClaims{})
@@ -116,6 +119,8 @@ func tokenExpired(token string) (bool, error) {
expirationTimestampNumeric, err := tokenParsed.Claims.GetExpirationTime()
if err != nil {
return false, fmt.Errorf("get expiration timestamp from access token: %w", err)
+ } else if expirationTimestampNumeric == nil {
+ return false, nil
}
expirationTimestamp := expirationTimestampNumeric.Time
now := time.Now()
@@ -157,24 +162,26 @@ func refreshTokens(utf *userTokenFlow) (err error) {
}
func buildRequestToRefreshTokens(utf *userTokenFlow) (*http.Request, error) {
+ idpClientID, err := getIDPClientID()
+ if err != nil {
+ 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,
- fmt.Sprintf("https://%s/token", authDomain),
- http.NoBody,
+ utf.tokenEndpoint,
+ strings.NewReader(form.Encode()),
)
+ req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
if err != nil {
return nil, err
}
- reqQuery := url.Values{}
- reqQuery.Set("grant_type", "refresh_token")
- reqQuery.Set("client_id", clientId)
- reqQuery.Set("refresh_token", utf.refreshToken)
- reqQuery.Set("token_format", "jwt")
- req.URL.RawQuery = reqQuery.Encode()
-
- // without this header, the API returns error "An Authentication object was not found in the SecurityContext"
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
return req, nil
}
diff --git a/internal/pkg/auth/user_token_flow_test.go b/internal/pkg/auth/user_token_flow_test.go
index b89f29bb8..cd31350ad 100644
--- a/internal/pkg/auth/user_token_flow_test.go
+++ b/internal/pkg/auth/user_token_flow_test.go
@@ -14,6 +14,10 @@ import (
"github.com/zalando/go-keyring"
)
+const (
+ testTokenEndpoint = "https://accounts.stackit.cloud/oauth/v2/token" //nolint:gosec // linter false positive
+)
+
type clientTransport struct {
t *testing.T // May write test errors
requestURL string
@@ -28,11 +32,11 @@ func (rt *clientTransport) RoundTrip(req *http.Request) (*http.Response, error)
if reqURL == rt.requestURL {
return rt.roundTripRequest()
}
- if reqURL == fmt.Sprintf("%s/token", authDomain) {
+ if fmt.Sprintf("https://%s", reqURL) == testTokenEndpoint {
return rt.roundTripRefreshTokens()
}
- rt.t.Fatalf("unexpected request to \"%s\"", reqURL)
- return nil, fmt.Errorf("unexpected request to \"%s\"", reqURL)
+ rt.t.Fatalf("unexpected request to %q", reqURL)
+ return nil, fmt.Errorf("unexpected request to %q", reqURL)
}
func (rt *clientTransport) roundTripRequest() (*http.Response, error) {
@@ -163,6 +167,7 @@ func TestRoundTrip(t *testing.T) {
desc: "tokens expired",
accessTokenExpiresAt: time.Now().Add(-time.Hour),
refreshTokenExpiresAt: time.Now().Add(-time.Hour),
+ refreshTokensFails: true, // Fails because refresh token is expired
isValid: true,
expectedReautorizeUserCalled: true,
expectedTokensRefreshed: true,
@@ -190,9 +195,10 @@ func TestRoundTrip(t *testing.T) {
accessTokenExpiresAt: time.Now().Add(-time.Hour),
refreshTokenExpiresAt: time.Now().Add(time.Hour),
refreshTokenInvalid: true,
- isValid: false,
- expectedReautorizeUserCalled: false,
- expectedTokensRefreshed: false,
+ refreshTokensFails: true, // Fails because refresh token is invalid
+ isValid: true,
+ expectedReautorizeUserCalled: true,
+ expectedTokensRefreshed: true, // Refreshed during reauthorization
},
{
desc: "refresh token invalid but unused",
@@ -207,6 +213,7 @@ func TestRoundTrip(t *testing.T) {
desc: "authorize user fails",
accessTokenExpiresAt: time.Now().Add(-time.Hour),
refreshTokenExpiresAt: time.Now().Add(-time.Hour),
+ refreshTokensFails: true, // Fails because refresh token is expired
authorizeUserFails: true,
isValid: false,
expectedReautorizeUserCalled: true,
@@ -271,7 +278,7 @@ func TestRoundTrip(t *testing.T) {
authorizeUserCalled: &authorizeUserCalled,
tokensRefreshed: &tokensRefreshed,
}
- authorizeUserRoutine := func(p *print.Printer, isReauthentication bool) error {
+ authorizeUserRoutine := func(_ *print.Printer, _ bool) error {
return reauthorizeUser(authorizeUserContext)
}
@@ -302,20 +309,16 @@ func TestRoundTrip(t *testing.T) {
}
if !tt.isValid && err == nil {
- if err == nil {
- t.Errorf("should have failed")
- }
if requestSent {
- t.Errorf("request was sent")
+ t.Logf("request was sent")
}
+ t.Errorf("should have failed")
}
if tt.isValid && err != nil {
- if err != nil {
- t.Errorf("shouldn't have failed: %v", err)
- }
if !requestSent {
- t.Errorf("request wasn't sent")
+ t.Logf("request wasn't sent")
}
+ t.Errorf("shouldn't have failed: %v", err)
}
if authorizeUserCalled && !tt.expectedReautorizeUserCalled {
t.Errorf("reauthorizeUser was called")
@@ -351,8 +354,9 @@ func setAuthStorage(accessTokenExpiresAt, refreshTokenExpiresAt time.Time, acces
return fmt.Errorf("set auth flow type: %w", err)
}
err = SetAuthFieldMap(map[authFieldKey]string{
- ACCESS_TOKEN: accessToken,
- REFRESH_TOKEN: refreshToken,
+ ACCESS_TOKEN: accessToken,
+ REFRESH_TOKEN: refreshToken,
+ IDP_TOKEN_ENDPOINT: testTokenEndpoint,
})
if err != nil {
return fmt.Errorf("set refreshed tokens in auth storage: %w", err)
@@ -377,3 +381,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
new file mode 100644
index 000000000..4fa431262
--- /dev/null
+++ b/internal/pkg/auth/utils.go
@@ -0,0 +1,41 @@
+package auth
+
+import (
+ "fmt"
+
+ "github.com/spf13/viper"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/config"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+)
+
+type wellKnownConfig struct {
+ Issuer string `json:"issuer"`
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+}
+
+func getIDPWellKnownConfigURL() (wellKnownConfigURL string, err error) {
+ wellKnownConfigURL = defaultWellKnownConfig
+
+ customWellKnownConfig := viper.GetString(config.IdentityProviderCustomWellKnownConfigurationKey)
+ if customWellKnownConfig != "" {
+ wellKnownConfigURL = customWellKnownConfig
+ err := utils.ValidateURLDomain(wellKnownConfigURL)
+ if err != nil {
+ return "", fmt.Errorf("validate custom identity provider well-known configuration: %w", err)
+ }
+ }
+
+ return wellKnownConfigURL, nil
+}
+
+func getIDPClientID() (string, error) {
+ idpClientID := defaultCLIClientID
+
+ customIDPClientID := viper.GetString(config.IdentityProviderCustomClientIdKey)
+ if customIDPClientID != "" {
+ idpClientID = customIDPClientID
+ }
+
+ return idpClientID, nil
+}
diff --git a/internal/pkg/auth/utils_test.go b/internal/pkg/auth/utils_test.go
new file mode 100644
index 000000000..8112257d6
--- /dev/null
+++ b/internal/pkg/auth/utils_test.go
@@ -0,0 +1,120 @@
+package auth
+
+import (
+ "testing"
+
+ "github.com/spf13/viper"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/config"
+)
+
+func TestGetWellKnownConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ idpCustomEndpoint string
+ allowedUrlDomain string
+ isValid bool
+ expected string
+ }{
+ {
+ name: "custom endpoint specified",
+ idpCustomEndpoint: "https://example.stackit.cloud",
+ allowedUrlDomain: "stackit.cloud",
+ isValid: true,
+ expected: "https://example.stackit.cloud",
+ },
+ {
+ name: "custom endpoint outside STACKIT",
+ idpCustomEndpoint: "https://www.very-suspicious-website.com/",
+ allowedUrlDomain: "stackit.cloud",
+ isValid: false,
+ },
+ {
+ name: "non-STACKIT custom endpoint invalid",
+ idpCustomEndpoint: "https://www.very-suspicious-website.com/",
+ allowedUrlDomain: "stackit.cloud",
+ isValid: false,
+ },
+ {
+ name: "non-STACKIT custom endpoint valid",
+ idpCustomEndpoint: "https://www.test.example.com/",
+ allowedUrlDomain: "example.com",
+ isValid: true,
+ expected: "https://www.test.example.com/",
+ },
+ {
+ name: "every URL valid",
+ idpCustomEndpoint: "https://www.test.example.com/",
+ allowedUrlDomain: "",
+ isValid: true,
+ expected: "https://www.test.example.com/",
+ },
+ {
+ name: "custom endpoint not specified",
+ idpCustomEndpoint: "",
+ allowedUrlDomain: "",
+ isValid: true,
+ expected: defaultWellKnownConfig,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ viper.Reset()
+ viper.Set(config.IdentityProviderCustomWellKnownConfigurationKey, tt.idpCustomEndpoint)
+ viper.Set(config.AllowedUrlDomainKey, tt.allowedUrlDomain)
+
+ got, err := getIDPWellKnownConfigURL()
+
+ 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 got != tt.expected {
+ t.Fatalf("expected idp endpoint %q, got %q", tt.expected, got)
+ }
+ })
+ }
+}
+
+func TestGetIDPClientID(t *testing.T) {
+ tests := []struct {
+ name string
+ idpCustomClientID string
+ isValid bool
+ expected string
+ }{
+ {
+ name: "custom client ID specified",
+ idpCustomClientID: "custom-client-id",
+ isValid: true,
+ expected: "custom-client-id",
+ },
+ {
+ name: "custom client ID not specified",
+ idpCustomClientID: "",
+ isValid: true,
+ expected: defaultCLIClientID,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ viper.Reset()
+ viper.Set(config.IdentityProviderCustomClientIdKey, tt.idpCustomClientID)
+
+ got, err := getIDPClientID()
+
+ 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 got != tt.expected {
+ t.Fatalf("expected idp client ID %q, got %q", tt.expected, got)
+ }
+ })
+ }
+}
diff --git a/internal/pkg/cache/cache.go b/internal/pkg/cache/cache.go
index d52670fd4..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 {
@@ -43,12 +118,22 @@ func PutObject(identifier string, data []byte) error {
return ErrorInvalidCacheIdentifier
}
- err := os.MkdirAll(cacheFolderPath, os.ModePerm)
+ err := os.MkdirAll(cacheFolderPath, 0o750)
if err != nil {
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 4ea003116..4ef45891b 100644
--- a/internal/pkg/cache/cache_test.go
+++ b/internal/pkg/cache/cache_test.go
@@ -6,10 +6,20 @@ 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 +27,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 +45,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, os.ModePerm)
- 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 +52,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 +113,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 +138,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 +176,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 +198,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 a27b13eff..e8532775f 100644
--- a/internal/pkg/config/config.go
+++ b/internal/pkg/config/config.go
@@ -14,32 +14,54 @@ const (
AsyncKey = "async"
OutputFormatKey = "output_format"
ProjectIdKey = "project_id"
+ RegionKey = "region"
SessionTimeLimitKey = "session_time_limit"
VerbosityKey = "verbosity"
- ArgusCustomEndpointKey = "argus_custom_endpoint"
- AuthorizationCustomEndpointKey = "authorization_custom_endpoint"
- DNSCustomEndpointKey = "dns_custom_endpoint"
- LoadBalancerCustomEndpointKey = "load_balancer_custom_endpoint"
- LogMeCustomEndpointKey = "logme_custom_endpoint"
- MariaDBCustomEndpointKey = "mariadb_custom_endpoint"
- MongoDBFlexCustomEndpointKey = "mongodbflex_custom_endpoint"
- ObjectStorageCustomEndpointKey = "object_storage_custom_endpoint"
- OpenSearchCustomEndpointKey = "opensearch_custom_endpoint"
- PostgresFlexCustomEndpointKey = "postgresflex_custom_endpoint"
- RabbitMQCustomEndpointKey = "rabbitmq_custom_endpoint"
- RedisCustomEndpointKey = "redis_custom_endpoint"
- ResourceManagerEndpointKey = "resource_manager_custom_endpoint"
- SecretsManagerCustomEndpointKey = "secrets_manager_custom_endpoint"
- ServiceAccountCustomEndpointKey = "service_account_custom_endpoint"
- SKECustomEndpointKey = "ske_custom_endpoint"
- SQLServerFlexCustomEndpointKey = "sqlserverflex_custom_endpoint"
+ 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"
+ MongoDBFlexCustomEndpointKey = "mongodbflex_custom_endpoint"
+ ObjectStorageCustomEndpointKey = "object_storage_custom_endpoint"
+ ObservabilityCustomEndpointKey = "observability_custom_endpoint"
+ OpenSearchCustomEndpointKey = "opensearch_custom_endpoint"
+ PostgresFlexCustomEndpointKey = "postgresflex_custom_endpoint"
+ RabbitMQCustomEndpointKey = "rabbitmq_custom_endpoint"
+ 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
- SessionTimeLimitDefault = "2h"
+ RegionDefault = "eu01"
+ SessionTimeLimitDefault = "12h"
+
+ AllowedUrlDomainDefault = "stackit.cloud"
)
const (
@@ -57,10 +79,16 @@ var ConfigKeys = []string{
AsyncKey,
OutputFormatKey,
ProjectIdKey,
+ RegionKey,
SessionTimeLimitKey,
VerbosityKey,
+ IdentityProviderCustomWellKnownConfigurationKey,
+ IdentityProviderCustomClientIdKey,
+ AllowedUrlDomainKey,
+
DNSCustomEndpointKey,
+ EdgeCustomEndpointKey,
LoadBalancerCustomEndpointKey,
LogMeCustomEndpointKey,
MariaDBCustomEndpointKey,
@@ -68,16 +96,28 @@ var ConfigKeys = []string{
OpenSearchCustomEndpointKey,
PostgresFlexCustomEndpointKey,
ResourceManagerEndpointKey,
- ArgusCustomEndpointKey,
+ ObservabilityCustomEndpointKey,
AuthorizationCustomEndpointKey,
MongoDBFlexCustomEndpointKey,
RabbitMQCustomEndpointKey,
RedisCustomEndpointKey,
ResourceManagerEndpointKey,
SecretsManagerCustomEndpointKey,
+ KMSCustomEndpointKey,
ServiceAccountCustomEndpointKey,
+ ServiceEnablementCustomEndpointKey,
+ ServerBackupCustomEndpointKey,
+ ServerOsUpdateCustomEndpointKey,
+ RunCommandCustomEndpointKey,
SKECustomEndpointKey,
SQLServerFlexCustomEndpointKey,
+ IaaSCustomEndpointKey,
+ TokenCustomEndpointKey,
+ GitCustomEndpointKey,
+ IntakeCustomEndpointKey,
+ AlbCustomEndpoint,
+ LogsCustomEndpointKey,
+ CDNCustomEndpointKey,
}
var defaultConfigFolderPath string
@@ -85,7 +125,11 @@ var configFolderPath string
var profileFilePath string
func InitConfig() {
- defaultConfigFolderPath = getInitialConfigDir()
+ initConfig(getInitialConfigDir())
+}
+
+func initConfig(configPath string) {
+ defaultConfigFolderPath = configPath
profileFilePath = getInitialProfileFilePath() // Profile file path is in the default config folder
configProfile, err := GetProfile()
@@ -98,6 +142,7 @@ func InitConfig() {
// This hack is required to allow creating the config file with `viper.WriteConfig`
// see https://github.com/spf13/viper/issues/851#issuecomment-789393451
viper.SetConfigFile(configFilePath)
+ viper.SetConfigType(configFileExtension)
f, err := os.Open(configFilePath)
if !os.IsNotExist(err) {
@@ -121,7 +166,7 @@ func InitConfig() {
// Write saves the config file (wrapping `viper.WriteConfig`) and ensures that its directory exists
func Write() error {
- err := os.MkdirAll(configFolderPath, os.ModePerm)
+ err := os.MkdirAll(configFolderPath, 0o750)
if err != nil {
return fmt.Errorf("create config directory: %w", err)
}
@@ -134,9 +179,14 @@ func setConfigDefaults() {
viper.SetDefault(AsyncKey, AsyncDefault)
viper.SetDefault(OutputFormatKey, "")
viper.SetDefault(ProjectIdKey, "")
+ viper.SetDefault(RegionKey, RegionDefault)
viper.SetDefault(SessionTimeLimitKey, SessionTimeLimitDefault)
+ viper.SetDefault(IdentityProviderCustomWellKnownConfigurationKey, "")
+ viper.SetDefault(IdentityProviderCustomClientIdKey, "")
+ viper.SetDefault(AllowedUrlDomainKey, AllowedUrlDomainDefault)
viper.SetDefault(DNSCustomEndpointKey, "")
- viper.SetDefault(ArgusCustomEndpointKey, "")
+ viper.SetDefault(EdgeCustomEndpointKey, "")
+ viper.SetDefault(ObservabilityCustomEndpointKey, "")
viper.SetDefault(AuthorizationCustomEndpointKey, "")
viper.SetDefault(MongoDBFlexCustomEndpointKey, "")
viper.SetDefault(ObjectStorageCustomEndpointKey, "")
@@ -144,9 +194,21 @@ func setConfigDefaults() {
viper.SetDefault(PostgresFlexCustomEndpointKey, "")
viper.SetDefault(ResourceManagerEndpointKey, "")
viper.SetDefault(SecretsManagerCustomEndpointKey, "")
+ viper.SetDefault(KMSCustomEndpointKey, "")
viper.SetDefault(ServiceAccountCustomEndpointKey, "")
+ viper.SetDefault(ServiceEnablementCustomEndpointKey, "")
+ viper.SetDefault(ServerBackupCustomEndpointKey, "")
+ viper.SetDefault(ServerOsUpdateCustomEndpointKey, "")
+ viper.SetDefault(RunCommandCustomEndpointKey, "")
viper.SetDefault(SKECustomEndpointKey, "")
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/config_test.go b/internal/pkg/config/config_test.go
index 7c9954117..a8f1e71af 100644
--- a/internal/pkg/config/config_test.go
+++ b/internal/pkg/config/config_test.go
@@ -37,7 +37,7 @@ func TestWrite(t *testing.T) {
configFolderPath = filepath.Dir(configPath)
if tt.folderExists {
- err := os.MkdirAll(configFolderPath, os.ModePerm)
+ err := os.MkdirAll(configFolderPath, 0o750)
if err != nil {
t.Fatalf("expected error to be nil, got %v", err)
}
diff --git a/internal/pkg/config/profiles.go b/internal/pkg/config/profiles.go
index f05ae9a7b..17d2fd352 100644
--- a/internal/pkg/config/profiles.go
+++ b/internal/pkg/config/profiles.go
@@ -1,6 +1,7 @@
package config
import (
+ "encoding/json"
"fmt"
"os"
"path/filepath"
@@ -78,8 +79,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)
@@ -97,10 +98,19 @@ 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)
}
- err = os.MkdirAll(configFolderPath, os.ModePerm)
+ err = os.MkdirAll(configFolderPath, 0o750)
if err != nil {
return fmt.Errorf("create config folder: %w", err)
}
@@ -190,7 +200,7 @@ func SetProfile(p *print.Printer, profile string) error {
profileFilePath = getInitialProfileFilePath()
}
- err = os.WriteFile(profileFilePath, []byte(profile), os.ModePerm)
+ err = os.WriteFile(profileFilePath, []byte(profile), 0o600)
if err != nil {
return fmt.Errorf("write profile to file: %w", err)
}
@@ -330,3 +340,108 @@ func DeleteProfile(p *print.Printer, profile string) error {
return nil
}
+
+// ImportProfile imports a profile configuration
+// It imports the profile with the name profileName and a config json.
+// If setAsActive is true, it set the new profile as the active profile.
+func ImportProfile(p *print.Printer, profileName, config string, setAsActive bool) error {
+ err := ValidateProfile(profileName)
+ if err != nil || profileName == DefaultProfileName {
+ return &errors.InvalidProfileNameError{Profile: profileName}
+ }
+
+ exists, err := ProfileExists(profileName)
+ if err != nil {
+ return fmt.Errorf("check if profile exists: %w", err)
+ }
+ if exists {
+ return &errors.ProfileAlreadyExistsError{Profile: profileName}
+ }
+
+ importConfig := &map[string]interface{}{}
+ err = json.Unmarshal([]byte(config), importConfig)
+ if err != nil {
+ return fmt.Errorf("unmarshal config: %w", err)
+ }
+
+ configFolderPath = GetProfileFolderPath(profileName)
+ err = os.MkdirAll(configFolderPath, 0o750)
+ if err != nil {
+ return fmt.Errorf("create config folder: %w", err)
+ }
+
+ content, err := json.MarshalIndent(importConfig, "", " ")
+ if err != nil {
+ cleanupErr := os.RemoveAll(configFolderPath)
+ if cleanupErr != nil {
+ return fmt.Errorf("json marshal config: %w, cleanup directories: %w", err, cleanupErr)
+ }
+ return fmt.Errorf("marshal config file: %w", err)
+ }
+
+ filePath := getConfigFilePath(configFolderPath)
+ err = os.WriteFile(filePath, content, 0o600)
+ if err != nil {
+ cleanupErr := os.RemoveAll(configFolderPath)
+ if cleanupErr != nil {
+ return fmt.Errorf("write config file: %w, cleanup directories: %w", err, cleanupErr)
+ }
+ return fmt.Errorf("write config file: %w", err)
+ }
+
+ if p.IsVerbosityDebug() {
+ p.Debug(print.DebugLevel, "profile %q imported", profileName)
+ }
+
+ if setAsActive {
+ err := SetProfile(&print.Printer{}, profileName)
+ if err != nil {
+ return fmt.Errorf("set active profile: %w", err)
+ }
+ }
+
+ if p.IsVerbosityDebug() {
+ p.Debug(print.DebugLevel, "active profile %q is now active", profileName)
+ }
+
+ return nil
+}
+
+// ExportProfile exports a profile configuration
+// Is exports the profile to the exportPath. The exportPath must contain the filename.
+func ExportProfile(p *print.Printer, profile, exportPath string) error {
+ err := ValidateProfile(profile)
+ if err != nil {
+ return fmt.Errorf("validate profile name: %w", err)
+ }
+
+ exists, err := ProfileExists(profile)
+ if err != nil {
+ return fmt.Errorf("check if profile exists: %w", err)
+ }
+ if !exists {
+ return &errors.ProfileDoesNotExistError{Profile: profile}
+ }
+
+ profilePath := GetProfileFolderPath(profile)
+ configFile := getConfigFilePath(profilePath)
+
+ stats, err := os.Stat(exportPath)
+ if err == nil {
+ if stats.IsDir() {
+ return fmt.Errorf("export path %q is a directory. Please specify a full path", exportPath)
+ }
+ return &errors.FileAlreadyExistsError{Filename: exportPath}
+ }
+
+ err = fileutils.CopyFile(configFile, exportPath)
+ if err != nil {
+ return fmt.Errorf("export config file to %q: %w", exportPath, err)
+ }
+
+ if p != nil {
+ p.Debug(print.DebugLevel, "exported profile %q to %q", profile, exportPath)
+ }
+
+ return nil
+}
diff --git a/internal/pkg/config/profiles_test.go b/internal/pkg/config/profiles_test.go
index bb96918c3..0039dc024 100644
--- a/internal/pkg/config/profiles_test.go
+++ b/internal/pkg/config/profiles_test.go
@@ -1,10 +1,18 @@
package config
import (
+ _ "embed"
+ "fmt"
+ "os"
"path/filepath"
"testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
)
+//go:embed template/test_profile.json
+var templateConfig string
+
func TestValidateProfile(t *testing.T) {
tests := []struct {
description string
@@ -115,3 +123,150 @@ func TestGetProfileFolderPath(t *testing.T) {
})
}
}
+
+func TestImportProfile(t *testing.T) {
+ tests := []struct {
+ description string
+ profile string
+ config string
+ setAsActive bool
+ isValid bool
+ }{
+ {
+ description: "valid profile",
+ profile: "profile-name",
+ config: templateConfig,
+ setAsActive: false,
+ isValid: true,
+ },
+ {
+ description: "invalid profile name",
+ profile: "invalid-profile-&",
+ config: templateConfig,
+ setAsActive: false,
+ isValid: false,
+ },
+ {
+ description: "invalid config",
+ profile: "my-profile",
+ config: `{ "invalid": "json }`,
+ setAsActive: false,
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ err := ImportProfile(p, tt.profile, tt.config, tt.setAsActive)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("profile should be valid but got error: %v\n", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("profile should be invalid but got no error\n")
+ }
+ })
+
+ t.Cleanup(func() {
+ p := print.NewPrinter()
+ err := DeleteProfile(p, tt.profile)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ fmt.Printf("could not clean up imported profile: %v\n", err)
+ }
+ })
+ }
+}
+
+func TestExportProfile(t *testing.T) {
+ // Create directory where the export configs should be stored
+ testDir, err := os.MkdirTemp(os.TempDir(), "stackit-cli-test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ func(path string) {
+ err := os.RemoveAll(path)
+ if err != nil {
+ fmt.Printf("could not clean up temp dir: %v\n", err)
+ }
+ }(testDir)
+ })
+
+ // Create test config directory
+ testConfigFolderPath := filepath.Join(testDir, "config")
+ initConfig(testConfigFolderPath)
+ err = Write()
+ if err != nil {
+ t.Fatalf("could not write profile, %v", err)
+ }
+
+ // Create prerequisite profile
+ p := print.NewPrinter()
+ profileName := "export-profile-test"
+ err = CreateProfile(p, profileName, true, false, false)
+ if err != nil {
+ t.Fatalf("could not create prerequisite profile, %v", err)
+ }
+ t.Cleanup(func() {
+ func(p *print.Printer, profile string) {
+ err := DeleteProfile(p, profile)
+ if err != nil {
+ fmt.Printf("could not clean up prerequisite profile %q, %v", profileName, err)
+ }
+ }(p, profileName)
+ })
+
+ tests := []struct {
+ description string
+ profile string
+ filePath string
+ isValid bool
+ }{
+ {
+ description: "valid profile",
+ profile: profileName,
+ filePath: filepath.Join(testDir, fmt.Sprintf("custom-name.%s", configFileExtension)),
+ isValid: true,
+ },
+ {
+ description: "invalid profile",
+ profile: "invalid-my-profile",
+ isValid: false,
+ },
+ {
+ description: "not existing path",
+ profile: profileName,
+ filePath: filepath.Join(testDir, "invalid", "path", fmt.Sprintf("custom-name.%s", configFileExtension)),
+ isValid: false,
+ },
+ {
+ description: "export without file extension",
+ profile: profileName,
+ filePath: filepath.Join(testDir, "file-without-extension"),
+ isValid: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ p := print.NewPrinter()
+ err := ExportProfile(p, tt.profile, tt.filePath)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("export should be valid but got error: %v\n", err)
+ }
+ if !tt.isValid {
+ t.Fatalf("export should be invalid but got no error\n")
+ }
+ })
+ }
+}
diff --git a/internal/pkg/config/template/test_profile.json b/internal/pkg/config/template/test_profile.json
new file mode 100644
index 000000000..ed2702e7e
--- /dev/null
+++ b/internal/pkg/config/template/test_profile.json
@@ -0,0 +1,35 @@
+{
+ "allowed_url_domain": "stackit.cloud",
+ "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": "",
+ "load_balancer_custom_endpoint": "",
+ "logme_custom_endpoint": "",
+ "mariadb_custom_endpoint": "",
+ "mongodbflex_custom_endpoint": "",
+ "object_storage_custom_endpoint": "",
+ "observability_custom_endpoint": "",
+ "opensearch_custom_endpoint": "",
+ "output_format": "",
+ "postgresflex_custom_endpoint": "",
+ "project_id": "",
+ "project_name": "",
+ "rabbitmq_custom_endpoint": "",
+ "redis_custom_endpoint": "",
+ "resource_manager_custom_endpoint": "",
+ "runcommand_custom_endpoint": "",
+ "secrets_manager_custom_endpoint": "",
+ "serverbackup_custom_endpoint": "",
+ "service_account_custom_endpoint": "",
+ "service_enablement_custom_endpoint": "",
+ "session_time_limit": "12h",
+ "sfs_custom_endpoint": "",
+ "ske_custom_endpoint": "",
+ "sqlserverflex_custom_endpoint": "",
+ "token_custom_endpoint": "",
+ "verbosity": "info"
+}
diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go
index 7ebe01ad5..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.`
@@ -32,6 +45,22 @@ You can authenticate as a user by running:
or use a service account by running:
$ stackit auth activate-service-account`
+ SESSION_EXPIRED = `Session is expired. Please log in again first.
+
+You can authenticate as a user by running:
+$ stackit auth login
+
+or use a service account by running:
+$ stackit auth activate-service-account`
+
+ ACCESS_TOKEN_EXPIRED = `Access token is expired. Please log in again first.
+
+You can authenticate as a user by running:
+$ stackit auth login
+
+or use a service account by running:
+$ stackit auth activate-service-account`
+
FAILED_SERVICE_ACCOUNT_ACTIVATION = `could not setup authentication based on the provided service account credentials.
Please double check if they are correctly configured.
@@ -50,7 +79,7 @@ To list all profiles, run:
DELETE_DEFAULT_PROFILE = `the default configuration profile %q cannot be deleted.`
- ARGUS_INVALID_INPUT_PLAN = `the instance plan was not correctly provided.
+ OBSERVABILITY_INVALID_INPUT_PLAN = `the instance plan was not correctly provided.
Either provide the plan ID:
$ %[1]s --plan-id [flags]
@@ -61,7 +90,7 @@ or provide plan name:
For more details on the available plans, run:
$ stackit %[2]s plans`
- ARGUS_INVALID_PLAN = `the provided instance plan is not valid.
+ OBSERVABILITY_INVALID_PLAN = `the provided instance plan is not valid.
%s
@@ -133,14 +162,103 @@ The profile name can only contain lowercase letters, numbers, and "-" and cannot
USAGE_TIP = `For usage help, run:
$ %s --help`
+
+ SERVICE_DISABLED = `This service isn't enabled for the current project.
+
+To enable it, run:
+ $ stackit %s enable`
+
+ IAAS_SERVER_MISSING_VOLUME_SIZE = `The "boot-volume-size" flag must be provided when "boot-volume-source-type" is "image".`
+
+ IAAS_SERVER_MISSING_VOLUME_ID = `The "boot-volume-source-id" flag must be provided together with "boot-volume-source-type" flag.`
+
+ IAAS_SERVER_MISSING_VOLUME_TYPE = `The "boot-volume-source-type" flag must be provided together with "boot-volume-source-id" flag.`
+
+ IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS = `Either "image-id" or "boot-volume-source-type" and "boot-volume-source-id" flags must be provided.`
+
+ IAAS_SERVER_NIC_ATTACH_MISSING_NIC_ID = `The "network-interface-id" flag must be provided if the "create" flag is not provided.`
+
+ IAAS_SERVER_NIC_DETACH_MISSING_NIC_ID = `The "network-interface-id" flag must be provided if the "delete" flag is not provided.`
+
+ PROFILE_ALREADY_EXISTS = `profile %[1]q already exists.
+
+To delete it, run:
+ $ stackit config profile delete %[1]s`
+
+ PROFILE_DOES_NOT_EXIST = `The profile %q does not exist.
+
+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 {
+ Cmd *cobra.Command
+}
+
+func (e *ServerNicAttachMissingNicIdError) Error() string {
+ return IAAS_SERVER_NIC_ATTACH_MISSING_NIC_ID
+}
+
+type ServerNicDetachMissingNicIdError struct {
+ Cmd *cobra.Command
+}
+
+func (e *ServerNicDetachMissingNicIdError) Error() string {
+ return IAAS_SERVER_NIC_DETACH_MISSING_NIC_ID
+}
+
+type ServerCreateMissingVolumeIdError struct {
+ Cmd *cobra.Command
+}
+
+func (e *ServerCreateMissingVolumeIdError) Error() string {
+ return IAAS_SERVER_MISSING_VOLUME_ID
+}
+
+type ServerCreateMissingVolumeTypeError struct {
+ Cmd *cobra.Command
+}
+
+func (e *ServerCreateMissingVolumeTypeError) Error() string {
+ return IAAS_SERVER_MISSING_VOLUME_TYPE
+}
+
+type ServerCreateMissingFlagsError struct {
+ Cmd *cobra.Command
+}
+
+func (e *ServerCreateMissingFlagsError) Error() string {
+ return IAAS_SERVER_MISSING_IMAGE_OR_VOLUME_FLAGS
+}
+
+type ServerCreateError struct {
+ Cmd *cobra.Command
+}
+
+func (e *ServerCreateError) Error() string {
+ return IAAS_SERVER_MISSING_VOLUME_SIZE
+}
+
type ProjectIdError struct{}
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 {
@@ -153,6 +271,18 @@ func (e *AuthError) Error() string {
return FAILED_AUTH
}
+type SessionExpiredError struct{}
+
+func (e *SessionExpiredError) Error() string {
+ return SESSION_EXPIRED
+}
+
+type AccessTokenExpiredError struct{}
+
+func (e *AccessTokenExpiredError) Error() string {
+ return ACCESS_TOKEN_EXPIRED
+}
+
type ActivateServiceAccountError struct{}
func (e *ActivateServiceAccountError) Error() string {
@@ -183,12 +313,12 @@ func (e *DeleteDefaultProfile) Error() string {
return fmt.Sprintf(DELETE_DEFAULT_PROFILE, e.DefaultProfile)
}
-type ArgusInputPlanError struct {
+type ObservabilityInputPlanError struct {
Cmd *cobra.Command
Args []string
}
-func (e *ArgusInputPlanError) Error() string {
+func (e *ObservabilityInputPlanError) Error() string {
fullCommandPath := e.Cmd.CommandPath()
if len(e.Args) > 0 {
fullCommandPath = fmt.Sprintf("%s %s", fullCommandPath, strings.Join(e.Args, " "))
@@ -196,16 +326,16 @@ func (e *ArgusInputPlanError) Error() string {
// Assumes a structure of the form "stackit "
service := e.Cmd.Parent().Parent().Use
- return fmt.Sprintf(ARGUS_INVALID_INPUT_PLAN, fullCommandPath, service)
+ return fmt.Sprintf(OBSERVABILITY_INVALID_INPUT_PLAN, fullCommandPath, service)
}
-type ArgusInvalidPlanError struct {
+type ObservabilityInvalidPlanError struct {
Service string
Details string
}
-func (e *ArgusInvalidPlanError) Error() string {
- return fmt.Sprintf(ARGUS_INVALID_PLAN, e.Details, e.Service)
+func (e *ObservabilityInvalidPlanError) Error() string {
+ return fmt.Sprintf(OBSERVABILITY_INVALID_PLAN, e.Details, e.Service)
}
type DSAInputPlanError struct {
@@ -347,7 +477,7 @@ type SubcommandMissingError struct {
}
func (e *SubcommandMissingError) Error() string {
- err := fmt.Errorf(SUBCOMMAND_MISSING)
+ err := fmt.Errorf("%s", SUBCOMMAND_MISSING)
return AppendUsageTip(err, e.Cmd).Error()
}
@@ -364,3 +494,193 @@ type InvalidProfileNameError struct {
func (e *InvalidProfileNameError) Error() string {
return fmt.Sprintf(INVALID_PROFILE_NAME, e.Profile)
}
+
+type ServiceDisabledError struct {
+ Service string
+}
+
+func (e *ServiceDisabledError) Error() string {
+ return fmt.Sprintf(SERVICE_DISABLED, e.Service)
+}
+
+type ProfileAlreadyExistsError struct {
+ Profile string
+}
+
+func (e *ProfileAlreadyExistsError) Error() string {
+ return fmt.Sprintf(PROFILE_ALREADY_EXISTS, e.Profile)
+}
+
+type ProfileDoesNotExistError struct {
+ Profile string
+}
+
+func (e *ProfileDoesNotExistError) Error() string {
+ return fmt.Sprintf(PROFILE_DOES_NOT_EXIST, e.Profile)
+}
+
+type FileAlreadyExistsError struct {
+ Filename string
+}
+
+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 67c37b775..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",
@@ -90,7 +98,7 @@ func TestSimpleErrors(t *testing.T) {
}
}
-func TestArgusInputPlanError(t *testing.T) {
+func TestObservabilityInputPlanError(t *testing.T) {
tests := []struct {
description string
args []string
@@ -99,19 +107,19 @@ func TestArgusInputPlanError(t *testing.T) {
{
description: "base",
args: []string{"arg1", "arg2"},
- expectedMsg: fmt.Sprintf(ARGUS_INVALID_INPUT_PLAN, "stackit service resource operation arg1 arg2", "service"),
+ expectedMsg: fmt.Sprintf(OBSERVABILITY_INVALID_INPUT_PLAN, "stackit service resource operation arg1 arg2", "service"),
},
{
description: "no args",
args: []string{},
- expectedMsg: fmt.Sprintf(ARGUS_INVALID_INPUT_PLAN, "stackit service resource operation", "service"),
+ expectedMsg: fmt.Sprintf(OBSERVABILITY_INVALID_INPUT_PLAN, "stackit service resource operation", "service"),
},
}
setupCmd()
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- err := &ArgusInputPlanError{
+ err := &ObservabilityInputPlanError{
Cmd: operation,
Args: tt.args,
}
@@ -149,7 +157,7 @@ func TestSetInexistentProfile(t *testing.T) {
}
}
-func TestArgusInvalidPlanError(t *testing.T) {
+func TestObservabilityInvalidPlanError(t *testing.T) {
tests := []struct {
description string
details string
@@ -160,25 +168,25 @@ func TestArgusInvalidPlanError(t *testing.T) {
description: "base",
details: "details",
service: "service",
- expectedMsg: fmt.Sprintf(ARGUS_INVALID_PLAN, "details", "service"),
+ expectedMsg: fmt.Sprintf(OBSERVABILITY_INVALID_PLAN, "details", "service"),
},
{
description: "no details",
details: "",
service: "service",
- expectedMsg: fmt.Sprintf(ARGUS_INVALID_PLAN, "", "service"),
+ expectedMsg: fmt.Sprintf(OBSERVABILITY_INVALID_PLAN, "", "service"),
},
{
description: "no service",
details: "details",
service: "",
- expectedMsg: fmt.Sprintf(ARGUS_INVALID_PLAN, "details", ""),
+ expectedMsg: fmt.Sprintf(OBSERVABILITY_INVALID_PLAN, "details", ""),
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- err := &ArgusInvalidPlanError{
+ err := &ObservabilityInvalidPlanError{
Service: tt.service,
Details: tt.details,
}
@@ -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/fileutils/file_utils_test.go b/internal/pkg/fileutils/file_utils_test.go
index e979bef94..587b476de 100644
--- a/internal/pkg/fileutils/file_utils_test.go
+++ b/internal/pkg/fileutils/file_utils_test.go
@@ -125,7 +125,7 @@ func TestCopyFile(t *testing.T) {
src := filepath.Join(basePath, "file-with-content.txt")
dst := filepath.Join(basePath, "file-with-content-copy.txt")
- err := os.MkdirAll(basePath, os.ModePerm)
+ err := os.MkdirAll(basePath, 0o750)
if err != nil {
t.Fatalf("unexpected error: %s", err.Error())
}
diff --git a/internal/pkg/flags/flag_to_value.go b/internal/pkg/flags/flag_to_value.go
index 6385ba65a..f08904982 100644
--- a/internal/pkg/flags/flag_to_value.go
+++ b/internal/pkg/flags/flag_to_value.go
@@ -47,6 +47,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
@@ -75,6 +89,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..08d25ed9b
--- /dev/null
+++ b/internal/pkg/flags/flag_to_value_test.go
@@ -0,0 +1,186 @@
+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/flags/flags_test.go b/internal/pkg/flags/flags_test.go
index 982f300b6..1021941de 100644
--- a/internal/pkg/flags/flags_test.go
+++ b/internal/pkg/flags/flags_test.go
@@ -83,7 +83,7 @@ func TestEnumFlag(t *testing.T) {
flag := EnumFlag(tt.ignoreCase, "", options...)
cmd := &cobra.Command{
Use: "test",
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, _ []string) error {
return nil
},
}
@@ -236,7 +236,7 @@ func TestEnumSliceFlag(t *testing.T) {
flag := EnumSliceFlag(tt.ignoreCase, tt.defaultValue, options...)
cmd := &cobra.Command{
Use: "test",
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, _ []string) error {
return nil
},
}
@@ -302,7 +302,7 @@ func TestUUIDFlag(t *testing.T) {
flag := UUIDFlag()
cmd := &cobra.Command{
Use: "test",
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, _ []string) error {
return nil
},
}
@@ -400,7 +400,7 @@ func TestUUIDSliceFlag(t *testing.T) {
flag := UUIDSliceFlag()
cmd := &cobra.Command{
Use: "test",
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, _ []string) error {
return nil
},
}
@@ -513,7 +513,7 @@ func TestCIDRFlag(t *testing.T) {
flag := CIDRFlag()
cmd := &cobra.Command{
Use: "test",
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, _ []string) error {
return nil
},
}
@@ -615,7 +615,7 @@ func TestCIDRSliceFlag(t *testing.T) {
flag := CIDRSliceFlag()
cmd := &cobra.Command{
Use: "test",
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, _ []string) error {
return nil
},
}
@@ -717,7 +717,7 @@ func TestReadFromFileFlag(t *testing.T) {
}
cmd := &cobra.Command{
Use: "test",
- RunE: func(cmd *cobra.Command, args []string) error {
+ RunE: func(_ *cobra.Command, _ []string) error {
return nil
},
}
diff --git a/internal/pkg/services/argus/client/client.go b/internal/pkg/generic-client/generic_client.go
similarity index 50%
rename from internal/pkg/services/argus/client/client.go
rename to internal/pkg/generic-client/generic_client.go
index c2f3a11da..64ede2600 100644
--- a/internal/pkg/services/argus/client/client.go
+++ b/internal/pkg/generic-client/generic_client.go
@@ -1,44 +1,49 @@
-package client
+package genericclient
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"
"github.com/stackitcloud/stackit-cli/internal/pkg/print"
-
- "github.com/spf13/viper"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
)
-func ConfigureClient(p *print.Printer) (*argus.APIClient, error) {
- var err error
- var apiClient *argus.APIClient
- var cfgOptions []sdkConfig.ConfigurationOption
+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 nil, &errors.AuthError{}
+ return zero, &errors.AuthError{}
+ }
+ cfgOptions := []sdkConfig.ConfigurationOption{
+ utils.UserAgentConfigOption(cliVersion),
+ authCfgOption,
}
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- customEndpoint := viper.GetString(config.ArgusCustomEndpointKey)
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 = argus.NewAPIClient(cfgOptions...)
+ apiClient, err := createApiClient(cfgOptions...)
if err != nil {
p.Debug(print.ErrorLevel, "create new API client: %v", err)
- return nil, &errors.AuthError{}
+ return zero, &errors.AuthError{}
}
return apiClient, nil
diff --git a/internal/pkg/globalflags/global_flags.go b/internal/pkg/globalflags/global_flags.go
index 47c48ea13..9f53ec4f7 100644
--- a/internal/pkg/globalflags/global_flags.go
+++ b/internal/pkg/globalflags/global_flags.go
@@ -17,6 +17,7 @@ const (
AssumeYesFlag = "assume-yes"
OutputFormatFlag = "output-format"
ProjectIdFlag = "project-id"
+ RegionFlag = "region"
VerbosityFlag = "verbosity"
DebugVerbosity = string(print.DebugLevel)
@@ -35,6 +36,7 @@ type GlobalFlagModel struct {
AssumeYes bool
OutputFormat string
ProjectId string
+ Region string
Verbosity string
}
@@ -65,6 +67,12 @@ func Configure(flagSet *pflag.FlagSet) error {
return fmt.Errorf("bind --%s flag to config: %w", VerbosityFlag, err)
}
+ flagSet.String(RegionFlag, "", "Target region for region-specific requests")
+ err = viper.BindPFlag(config.RegionKey, flagSet.Lookup(RegionFlag))
+ if err != nil {
+ return fmt.Errorf("bind --%s flag to config: %w", RegionFlag, err)
+ }
+
return nil
}
@@ -74,6 +82,7 @@ func Parse(p *print.Printer, cmd *cobra.Command) *GlobalFlagModel {
AssumeYes: flags.FlagToBoolValue(p, cmd, AssumeYesFlag),
OutputFormat: viper.GetString(config.OutputFormatKey),
ProjectId: viper.GetString(config.ProjectIdKey),
+ Region: viper.GetString(config.RegionKey),
Verbosity: viper.GetString(config.VerbosityKey),
}
}
diff --git a/internal/pkg/print/debug.go b/internal/pkg/print/debug.go
index d0927d8df..793c54bd3 100644
--- a/internal/pkg/print/debug.go
+++ b/internal/pkg/print/debug.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"slices"
"sort"
"strings"
@@ -15,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 {
@@ -141,13 +142,18 @@ func BuildDebugStrFromHTTPRequest(req *http.Request, includeHeaders []string) ([
return nil, fmt.Errorf("request is invalid")
}
- status := fmt.Sprintf("request to %s: %s %s", req.URL, req.Method, req.Proto)
+ // unescape url in order to get rid of e.g. %40
+ unescapedURL, err := url.PathUnescape(req.URL.String())
+ if err != nil {
+ return nil, fmt.Errorf("unescape request url: %w", err)
+ }
+
+ status := fmt.Sprintf("request to %s: %s %s", unescapedURL, req.Method, req.Proto)
headersMap := buildHeaderMap(req.Header, includeHeaders)
headers := fmt.Sprintf("request headers: %v", BuildDebugStrFromMap(headersMap))
var save io.ReadCloser
- var err error
save, req.Body, err = drainBody(req.Body)
if err != nil {
@@ -184,13 +190,19 @@ func BuildDebugStrFromHTTPResponse(resp *http.Response, includeHeaders []string)
return nil, fmt.Errorf("response is invalid")
}
- status := fmt.Sprintf("response from %s: %s %s", resp.Request.URL, resp.Proto, resp.Status)
+ var err error
+ // unescape url in order to get rid of e.g. %40
+ unescapedURL, err := url.PathUnescape(resp.Request.URL.String())
+ if err != nil {
+ return nil, fmt.Errorf("unescape response url: %w", err)
+ }
+
+ status := fmt.Sprintf("response from %s: %s %s", unescapedURL, resp.Proto, resp.Status)
headersMap := buildHeaderMap(resp.Header, includeHeaders)
headers := fmt.Sprintf("response headers: %v", BuildDebugStrFromMap(headersMap))
var save io.ReadCloser
- var err error
save, resp.Body, err = drainBody(resp.Body)
if err != nil {
diff --git a/internal/pkg/print/debug_test.go b/internal/pkg/print/debug_test.go
index 35c3dfb6a..abc3dedeb 100644
--- a/internal/pkg/print/debug_test.go
+++ b/internal/pkg/print/debug_test.go
@@ -78,6 +78,28 @@ func fixtureHTTPRequest(mods ...func(req *http.Request)) *http.Request {
return request
}
+func fixtureHTTPRequestUnescaped(mods ...func(req *http.Request)) *http.Request {
+ testBody, err := json.Marshal(map[string]string{"key": "value"})
+ if err != nil {
+ return nil
+ }
+
+ request, err := http.NewRequest("GET", "http://example.com/v2/projects?limit=50&member=User.Name%40stackit.cloud", bytes.NewReader(testBody))
+ if err != nil {
+ return nil
+ }
+
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Accept", "application/json")
+ request.Header.Set("Content-Length", "15")
+
+ for _, mod := range mods {
+ mod(request)
+ }
+
+ return request
+}
+
func fixtureHTTPResponse(mods ...func(resp *http.Response)) *http.Response {
testBody, err := json.Marshal(map[string]string{"key": "value"})
if err != nil {
@@ -149,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
@@ -402,6 +424,16 @@ func TestBuildDebugStrFromHTTPRequest(t *testing.T) {
},
isValid: true,
},
+ {
+ description: "unescaped test",
+ inputReq: fixtureHTTPRequestUnescaped(),
+ expected: []string{
+ "request to http://example.com/v2/projects?limit=50&member=User.Name@stackit.cloud: GET HTTP/1.1",
+ "request headers: [Accept: application/json, Content-Length: 15, Content-Type: application/json]",
+ "request body: [key: value]",
+ },
+ isValid: true,
+ },
}
for _, tt := range tests {
diff --git a/internal/pkg/print/print.go b/internal/pkg/print/print.go
index 470fb49ac..6754db285 100644
--- a/internal/pkg/print/print.go
+++ b/internal/pkg/print/print.go
@@ -2,10 +2,14 @@ package print
import (
"bufio"
+ "bytes"
+ "encoding/json"
"errors"
"fmt"
"syscall"
+ "github.com/goccy/go-yaml"
+
"log/slog"
"os"
"os/exec"
@@ -48,10 +52,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 +136,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 +163,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 +182,20 @@ 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))
+ if term.IsTerminal(syscall.Stdin) {
+ bytePassword, err := term.ReadPassword(syscall.Stdin)
+ 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
@@ -189,7 +211,8 @@ func (p *Printer) PagerDisplay(content string) error {
// -S: disables line wrapping
// -w: highlight the first line after moving one full page down
// -R: interprets ANSI color and style sequences
- pagerCmd := exec.Command("less", "-F", "-S", "-w", "-R")
+ // -K: exits if an interrupt character is typed
+ pagerCmd := exec.Command("less", "-F", "-S", "-w", "-R", "-K")
pager, pagerExists := os.LookupEnv("PAGER")
if pagerExists && pager != "nil" && pager != "" {
@@ -227,3 +250,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 f5841de37..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"
@@ -57,7 +58,8 @@ func TestOutputf(t *testing.T) {
t.Run(tt.description, func(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
- cmd.SetOutput(&buf)
+ cmd.SetOut(&buf)
+ cmd.SetErr(&buf)
p := &Printer{
Cmd: cmd,
Verbosity: tt.verbosity,
@@ -69,7 +71,7 @@ func TestOutputf(t *testing.T) {
}
if len(tt.args) == 0 {
- p.Outputf(tt.message)
+ p.Outputf("%s", tt.message)
} else {
p.Outputf(tt.message, tt.args...)
}
@@ -128,7 +130,8 @@ func TestOutputln(t *testing.T) {
t.Run(tt.description, func(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
- cmd.SetOutput(&buf)
+ cmd.SetOut(&buf)
+ cmd.SetErr(&buf)
p := &Printer{
Cmd: cmd,
Verbosity: tt.verbosity,
@@ -192,7 +195,8 @@ func TestPagerDisplay(t *testing.T) {
t.Run(tt.description, func(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
- cmd.SetOutput(&buf)
+ cmd.SetOut(&buf)
+ cmd.SetErr(&buf)
p := &Printer{
Cmd: cmd,
Verbosity: tt.verbosity,
@@ -291,7 +295,8 @@ func TestDebug(t *testing.T) {
t.Run(tt.description, func(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
- cmd.SetOutput(&buf)
+ cmd.SetOut(&buf)
+ cmd.SetErr(&buf)
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{AddSource: true, Level: slog.LevelDebug}))
slog.SetDefault(logger)
p := &Printer{
@@ -354,13 +359,14 @@ func TestInfo(t *testing.T) {
t.Run(tt.description, func(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
- cmd.SetOutput(&buf)
+ cmd.SetOut(&buf)
+ cmd.SetErr(&buf)
p := &Printer{
Cmd: cmd,
Verbosity: tt.verbosity,
}
- p.Info(tt.message)
+ p.Info("%s", tt.message)
expectedOutput := tt.message
output := buf.String()
@@ -413,13 +419,14 @@ func TestWarn(t *testing.T) {
t.Run(tt.description, func(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
- cmd.SetOutput(&buf)
+ cmd.SetOut(&buf)
+ cmd.SetErr(&buf)
p := &Printer{
Cmd: cmd,
Verbosity: tt.verbosity,
}
- p.Warn(tt.message)
+ p.Warn("%s", tt.message)
expectedOutput := fmt.Sprintf("Warning: %s", tt.message)
output := buf.String()
@@ -472,13 +479,14 @@ func TestError(t *testing.T) {
t.Run(tt.description, func(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
- cmd.SetOutput(&buf)
+ cmd.SetOut(&buf)
+ cmd.SetErr(&buf)
p := &Printer{
Cmd: cmd,
Verbosity: tt.verbosity,
}
- p.Error(tt.message)
+ p.Error("%s", tt.message)
expectedOutput := fmt.Sprintf("Error: %s\n", tt.message)
output := buf.String()
@@ -502,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
{
@@ -640,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 {
@@ -658,6 +674,7 @@ func TestPromptForConfirmation(t *testing.T) {
p := &Printer{
Cmd: cmd,
Verbosity: tt.verbosity,
+ AssumeYes: tt.assumeYes,
}
err = p.PromptForConfirmation("")
@@ -853,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 a258df6e1..b2c77117f 100644
--- a/internal/pkg/projectname/project_name.go
+++ b/internal/pkg/projectname/project_name.go
@@ -3,12 +3,14 @@ package projectname
import (
"context"
"fmt"
+ "os"
"github.com/stackitcloud/stackit-cli/internal/pkg/config"
"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/services/resourcemanager/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -17,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
@@ -28,20 +30,19 @@ 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)
}
- req := apiClient.GetProject(ctx, projectId)
- resp, err := req.Execute()
+
+ projectName, err := utils.GetProjectName(ctx, apiClient, projectId)
if err != nil {
- return "", fmt.Errorf("read project details: %w", err)
+ return "", fmt.Errorf("get project name: %w", err)
}
- projectName := *resp.Name
// If project ID is set in config, we store the project name in config
// (So next time we can just pull it from there)
- if !isProjectIdSetInFlags(p, cmd) {
+ if !isProjectIdSetInFlags(p, cmd) && !isProjectIdSetInEnvVar() {
viper.Set(config.ProjectNameKey, projectName)
err = config.Write()
if err != nil {
@@ -57,13 +58,11 @@ func useProjectNameFromConfig(p *print.Printer, cmd *cobra.Command) bool {
// We use the project name from the config file, if:
// - Project id is not set to a different value than the one in the config file
// - Project name in the config file is not empty
- projectIdSet := isProjectIdSetInFlags(p, cmd)
+ projectIdSetInFlags := isProjectIdSetInFlags(p, cmd)
+ projectIdSetInEnv := isProjectIdSetInEnvVar()
projectName := viper.GetString(config.ProjectNameKey)
- projectNameSet := false
- if projectName != "" {
- projectNameSet = true
- }
- return !projectIdSet && projectNameSet
+ projectNameSet := projectName != ""
+ return !projectIdSetInFlags && !projectIdSetInEnv && projectNameSet
}
func isProjectIdSetInFlags(p *print.Printer, cmd *cobra.Command) bool {
@@ -71,9 +70,12 @@ 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
}
+
+func isProjectIdSetInEnvVar() bool {
+ // Reads the project Id from the environment variable PROJECT_ID
+ _, projectIdSetInEnv := os.LookupEnv("STACKIT_PROJECT_ID")
+ return projectIdSetInEnv
+}
diff --git a/internal/pkg/projectname/project_name_test.go b/internal/pkg/projectname/project_name_test.go
index 25239c8f4..ae6d79251 100644
--- a/internal/pkg/projectname/project_name_test.go
+++ b/internal/pkg/projectname/project_name_test.go
@@ -42,7 +42,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
new file mode 100644
index 000000000..1de12c654
--- /dev/null
+++ b/internal/pkg/services/alb/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/alb"
+)
+
+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/alb/utils/utils.go b/internal/pkg/services/alb/utils/utils.go
new file mode 100644
index 000000000..7a32e1986
--- /dev/null
+++ b/internal/pkg/services/alb/utils/utils.go
@@ -0,0 +1,4 @@
+package utils
+
+type AlbClient interface {
+}
diff --git a/internal/pkg/services/alb/utils/utils_test.go b/internal/pkg/services/alb/utils/utils_test.go
new file mode 100644
index 000000000..489b7a2c5
--- /dev/null
+++ b/internal/pkg/services/alb/utils/utils_test.go
@@ -0,0 +1,4 @@
+package utils
+
+type AlbClientMocked struct {
+}
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..afefb7a92
--- /dev/null
+++ b/internal/pkg/services/cdn/client/client.go
@@ -0,0 +1,13 @@
+package client
+
+import (
+ "github.com/spf13/viper"
+ "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/stackitcloud/stackit-sdk-go/services/cdn"
+)
+
+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 da57eb96c..141fb50e0 100644
--- a/internal/pkg/services/dns/utils/utils.go
+++ b/internal/pkg/services/dns/utils/utils.go
@@ -3,7 +3,9 @@ package utils
import (
"context"
"fmt"
+ "math"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
)
@@ -27,3 +29,37 @@ func GetRecordSetName(ctx context.Context, apiClient DNSClient, projectId, zoneI
}
return *resp.Rrset.Name, nil
}
+
+func GetRecordSetType(ctx context.Context, apiClient DNSClient, projectId, zoneId, recordSetId string) (*string, error) {
+ resp, err := apiClient.GetRecordSetExecute(ctx, projectId, zoneId, recordSetId)
+ if err != nil {
+ return utils.Ptr(""), fmt.Errorf("get DNS recordset: %w", err)
+ }
+ return (*string)(resp.Rrset.Type), nil
+}
+
+func FormatTxtRecord(input string) (string, error) {
+ length := float64(len(input))
+ if length <= 255 {
+ return input, nil
+ }
+ // Max length with quotes and white spaces is 4096. Without the quotes and white spaces the max length is 4049
+ if length > 4049 {
+ return "", fmt.Errorf("max input length is 4049. The length of the input is %v", length)
+ }
+
+ result := ""
+ chunks := int(math.Ceil(length / 255))
+ for i := range chunks {
+ skip := 255 * i
+ if i == chunks-1 {
+ // Append the left record content
+ result += fmt.Sprintf("%q", input[0+skip:])
+ } else {
+ // Add 255 characters of the record data quoted to the result
+ result += fmt.Sprintf("%q ", input[0+skip:255+skip])
+ }
+ }
+
+ return result, nil
+}
diff --git a/internal/pkg/services/dns/utils/utils_test.go b/internal/pkg/services/dns/utils/utils_test.go
index 2c972218c..12cae8fdc 100644
--- a/internal/pkg/services/dns/utils/utils_test.go
+++ b/internal/pkg/services/dns/utils/utils_test.go
@@ -5,9 +5,8 @@ import (
"fmt"
"testing"
- "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
-
"github.com/google/uuid"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
)
@@ -15,11 +14,17 @@ var (
testProjectId = uuid.NewString()
testZoneId = uuid.NewString()
testRecordSetId = uuid.NewString()
+
+ text255Characters = "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoo"
+ text256Characters = "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoob"
+ result256Characters = "\"foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoo\" \"b\""
+ text4050Characters = "foobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoofoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoobarfoo"
)
const (
testZoneName = "zone"
testRecordSetName = "record-set"
+ testRecordSetType = "A"
)
type dnsClientMocked struct {
@@ -142,3 +147,118 @@ func TestGetRecordSetName(t *testing.T) {
})
}
}
+
+func TestGetRecordSetType(t *testing.T) {
+ tests := []struct {
+ description string
+ getRecordSetFails bool
+ getRecordSetResp *dns.RecordSetResponse
+ isValid bool
+ expectedOutput string
+ }{
+ {
+ description: "base",
+ getRecordSetResp: &dns.RecordSetResponse{
+ Rrset: &dns.RecordSet{
+ Name: utils.Ptr(testRecordSetType),
+ },
+ },
+ isValid: true,
+ expectedOutput: testRecordSetType,
+ },
+ {
+ description: "get record set fails",
+ getRecordSetFails: true,
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ client := &dnsClientMocked{
+ getRecordSetFails: tt.getRecordSetFails,
+ getRecordSetResp: tt.getRecordSetResp,
+ }
+
+ output, err := GetRecordSetName(context.Background(), client, testProjectId, testZoneId, testRecordSetId)
+
+ if tt.isValid && err != nil {
+ t.Errorf("failed on valid input")
+ }
+ if !tt.isValid && err == nil {
+ t.Errorf("did not fail on invalid input")
+ }
+ if !tt.isValid {
+ return
+ }
+ if output != tt.expectedOutput {
+ t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output)
+ }
+ })
+ }
+}
+
+func TestFormatTxtRecord(t *testing.T) {
+ tests := []struct {
+ description string
+ input string
+ expected string
+ isValid bool
+ }{
+ {
+ description: "base",
+ input: "foobar",
+ expected: "foobar",
+ isValid: true,
+ },
+ {
+ description: "empty",
+ input: "",
+ expected: "",
+ isValid: true,
+ },
+ {
+ description: "255 characters",
+ input: text255Characters,
+ expected: text255Characters,
+ isValid: true,
+ },
+ {
+ description: "256 characters",
+ input: text256Characters,
+ expected: result256Characters,
+ isValid: true,
+ },
+ {
+ description: "> 4049 characters should throw error",
+ input: text4050Characters,
+ isValid: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ result, err := FormatTxtRecord(tt.input)
+
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Errorf("failed on valid input, got %v", err)
+ return
+ }
+
+ if err == nil && !tt.isValid {
+ t.Errorf("did not fail on invalid input")
+ return
+ }
+
+ if !tt.isValid {
+ t.Errorf("did not fail on invalid input")
+ return
+ }
+ if result != tt.expected {
+ t.Errorf("expected result to be %s, got %s", tt.expected, result)
+ }
+ })
+ }
+}
diff --git a/internal/pkg/services/edge/client/client.go b/internal/pkg/services/edge/client/client.go
new file mode 100644
index 000000000..88ec7e2c4
--- /dev/null
+++ b/internal/pkg/services/edge/client/client.go
@@ -0,0 +1,36 @@
+// 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-cli/internal/pkg/config"
+ 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/edge"
+)
+
+// 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..59bbba0b0
--- /dev/null
+++ b/internal/pkg/services/edge/common/kubeconfig/kubeconfig_test.go
@@ -0,0 +1,744 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: 2025 STACKIT GmbH & Co. KG
+
+package kubeconfig
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ testUtils "github.com/stackitcloud/stackit-cli/internal/pkg/testutils"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "k8s.io/client-go/tools/clientcmd"
+)
+
+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..33a2d0c46
--- /dev/null
+++ b/internal/pkg/services/edge/common/validation/input.go
@@ -0,0 +1,51 @@
+// 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..0ccb2d3b2
--- /dev/null
+++ b/internal/pkg/services/edge/common/validation/input_test.go
@@ -0,0 +1,81 @@
+// 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..7ec5dc494
--- /dev/null
+++ b/internal/pkg/services/git/utils/utils_test.go
@@ -0,0 +1,66 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/git"
+)
+
+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
new file mode 100644
index 000000000..a49f04b7c
--- /dev/null
+++ b/internal/pkg/services/iaas/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/iaas"
+)
+
+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
new file mode 100644
index 000000000..b7973c265
--- /dev/null
+++ b/internal/pkg/services/iaas/utils/utils.go
@@ -0,0 +1,234 @@
+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, 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)
+ 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, 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, 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)
+ }
+ securityGroupRuleName := *resp.Ethertype + ", " + *resp.Direction
+ return securityGroupRuleName, nil
+}
+
+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, 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)
+ }
+ associatedResourceId := ""
+ if resp.NetworkInterface != nil {
+ associatedResourceId = *resp.NetworkInterface.Get()
+ }
+ return *resp.Ip, associatedResourceId, nil
+}
+
+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, 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, 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 GetNetworkAreaName(ctx context.Context, apiClient IaaSClient, organizationId, areaId string) (string, error) {
+ 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
+}
+
+func ListAttachedProjects(ctx context.Context, apiClient IaaSClient, organizationId, areaId string) ([]string, error) {
+ 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, 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)
+ }
+ return *resp.Prefix, nil
+}
+
+// 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(destination, nexthop string, routes *[]iaas.Route) (iaas.Route, error) {
+ for _, route := range *routes {
+ // 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")
+}
+
+// GetNetworkRangeFromAPIResponse returns the network range from the API response that matches the given prefix
+// This works because network range prefixes are unique in the same SNA
+func GetNetworkRangeFromAPIResponse(prefix string, networkRanges *[]iaas.NetworkRange) (iaas.NetworkRange, error) {
+ for _, networkRange := range *networkRanges {
+ if *networkRange.Prefix == prefix {
+ return networkRange, nil
+ }
+ }
+ return iaas.NetworkRange{}, fmt.Errorf("new network range not found in API response")
+}
+
+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)
+ } 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, 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
+ }
+ 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
new file mode 100644
index 000000000..a8f530533
--- /dev/null
+++ b/internal/pkg/services/iaas/utils/utils_test.go
@@ -0,0 +1,1095 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
+)
+
+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
+ GetBackupFails bool
+ GetBackupResp *iaas.Backup
+ GetSnapshotFails bool
+ GetSnapshotResp *iaas.Snapshot
+}
+
+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) {
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ if m.GetNetworkFails {
+ return nil, fmt.Errorf("could not get network")
+ }
+ return m.GetNetworkResp, nil
+}
+
+func (m *IaaSClientMocked) GetNetworkAreaExecute(_ context.Context, _, _ string) (*iaas.NetworkArea, error) {
+ if m.GetNetworkAreaFails {
+ return nil, fmt.Errorf("could not get network area")
+ }
+ return m.GetNetworkAreaResp, nil
+}
+
+func (m *IaaSClientMocked) ListNetworkAreaProjectsExecute(_ context.Context, _, _ string) (*iaas.ProjectListResponse, error) {
+ if m.GetAttachedProjectsFails {
+ return nil, fmt.Errorf("could not get attached projects")
+ }
+ return m.GetAttachedProjectsResp, nil
+}
+
+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) {
+ 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
+ getInstanceResp *iaas.SecurityGroupRule
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getInstanceResp: &iaas.SecurityGroupRule{
+ Ethertype: utils.Ptr("IPv6"),
+ Direction: utils.Ptr("ingress"),
+ },
+ },
+ want: "IPv6, ingress",
+ },
+ {
+ name: "get security group rule fails",
+ args: args{
+ getInstanceFails: true,
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := &IaaSClientMocked{
+ GetSecurityGroupRuleFails: tt.args.getInstanceFails,
+ GetSecurityGroupRuleResp: tt.args.getInstanceResp,
+ }
+ got, err := GetSecurityGroupRuleName(context.Background(), m, "", "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetSecurityGroupRuleName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetSecurityGroupRuleName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetSecurityGroupName(t *testing.T) {
+ type args struct {
+ getInstanceFails bool
+ getInstanceResp *iaas.SecurityGroup
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getInstanceResp: &iaas.SecurityGroup{
+ Name: utils.Ptr("test"),
+ },
+ },
+ want: "test",
+ },
+ {
+ name: "get security group fails",
+ args: args{
+ getInstanceFails: true,
+ },
+ 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) {
+ m := &IaaSClientMocked{
+ GetSecurityGroupFails: tt.args.getInstanceFails,
+ GetSecurityGroupResp: tt.args.getInstanceResp,
+ }
+ got, err := GetSecurityGroupName(context.Background(), m, "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetSecurityGroupName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetSecurityGroupName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetPublicIp(t *testing.T) {
+ type args struct {
+ getPublicIpFails bool
+ getPublicIpResp *iaas.PublicIp
+ }
+ tests := []struct {
+ name string
+ args args
+ wantPublicIp string
+ wantAssociatedResource string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getPublicIpResp: &iaas.PublicIp{
+ Ip: utils.Ptr("1.2.3.4"),
+ NetworkInterface: iaas.NewNullableString(utils.Ptr("5.6.7.8")),
+ },
+ },
+ wantPublicIp: "1.2.3.4",
+ wantAssociatedResource: "5.6.7.8",
+ },
+ {
+ name: "get public ip fails",
+ args: args{
+ getPublicIpFails: true,
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := &IaaSClientMocked{
+ GetPublicIpFails: tt.args.getPublicIpFails,
+ GetPublicIpResp: tt.args.getPublicIpResp,
+ }
+ gotPublicIP, gotAssociatedResource, err := GetPublicIP(context.Background(), m, "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetPublicIP() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if gotPublicIP != tt.wantPublicIp {
+ t.Errorf("GetPublicIP() = %v, want public IP %v", gotPublicIP, tt.wantPublicIp)
+ }
+ if gotAssociatedResource != tt.wantAssociatedResource {
+ t.Errorf("GetPublicIP() = %v, want associated resource %v", gotAssociatedResource, tt.wantAssociatedResource)
+ }
+ })
+ }
+}
+
+func TestGetServerName(t *testing.T) {
+ type args struct {
+ getInstanceFails bool
+ getInstanceResp *iaas.Server
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getInstanceResp: &iaas.Server{
+ Name: utils.Ptr("test"),
+ },
+ },
+ want: "test",
+ },
+ {
+ name: "get server fails",
+ args: args{
+ getInstanceFails: true,
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := &IaaSClientMocked{
+ GetServerFails: tt.args.getInstanceFails,
+ GetServerResp: tt.args.getInstanceResp,
+ }
+ got, err := GetServerName(context.Background(), m, "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetServerName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetServerName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetVolumeName(t *testing.T) {
+ type args struct {
+ getInstanceFails bool
+ getInstanceResp *iaas.Volume
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getInstanceResp: &iaas.Volume{
+ Name: utils.Ptr("test"),
+ },
+ },
+ want: "test",
+ },
+ {
+ name: "get volume fails",
+ args: args{
+ getInstanceFails: true,
+ },
+ 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) {
+ m := &IaaSClientMocked{
+ GetVolumeFails: tt.args.getInstanceFails,
+ GetVolumeResp: tt.args.getInstanceResp,
+ }
+ got, err := GetVolumeName(context.Background(), m, "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetVolumeName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetVolumeName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetNetworkName(t *testing.T) {
+ type args struct {
+ getInstanceFails bool
+ getInstanceResp *iaas.Network
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getInstanceResp: &iaas.Network{
+ Name: utils.Ptr("test"),
+ },
+ },
+ want: "test",
+ },
+ {
+ name: "get network fails",
+ args: args{
+ getInstanceFails: true,
+ },
+ 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) {
+ m := &IaaSClientMocked{
+ GetNetworkFails: tt.args.getInstanceFails,
+ GetNetworkResp: tt.args.getInstanceResp,
+ }
+ got, err := GetNetworkName(context.Background(), m, "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetNetworkName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetNetworkName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetNetworkAreaName(t *testing.T) {
+ type args struct {
+ getInstanceFails bool
+ getInstanceResp *iaas.NetworkArea
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getInstanceResp: &iaas.NetworkArea{
+ Name: utils.Ptr("test"),
+ },
+ },
+ want: "test",
+ },
+ {
+ name: "get network area 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.NetworkArea{
+ Name: nil,
+ },
+ getInstanceFails: false,
+ },
+ wantErr: true,
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := &IaaSClientMocked{
+ GetNetworkAreaFails: tt.args.getInstanceFails,
+ GetNetworkAreaResp: tt.args.getInstanceResp,
+ }
+ got, err := GetNetworkAreaName(context.Background(), m, "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetNetworkAreaName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetNetworkAreaName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestListAttachedProjects(t *testing.T) {
+ type args struct {
+ getAttachedProjectsFails bool
+ getAttachedProjectsResp *iaas.ProjectListResponse
+ }
+ tests := []struct {
+ name string
+ args args
+ want []string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getAttachedProjectsResp: &iaas.ProjectListResponse{
+ Items: &[]string{"test"},
+ },
+ },
+ want: []string{"test"},
+ },
+ {
+ name: "get attached projects fails",
+ args: args{
+ getAttachedProjectsFails: true,
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := &IaaSClientMocked{
+ GetAttachedProjectsFails: tt.args.getAttachedProjectsFails,
+ GetAttachedProjectsResp: tt.args.getAttachedProjectsResp,
+ }
+ got, err := ListAttachedProjects(context.Background(), m, "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetAttachedProjects() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", tt.want) {
+ t.Errorf("GetAttachedProjects() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetNetworkRangePrefix(t *testing.T) {
+ type args struct {
+ getNetworkAreaRangeFails bool
+ getNetworkAreaRangeResp *iaas.NetworkRange
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ getNetworkAreaRangeResp: &iaas.NetworkRange{
+ Prefix: utils.Ptr("test"),
+ },
+ },
+ want: "test",
+ },
+ {
+ name: "get network area range fails",
+ args: args{
+ getNetworkAreaRangeFails: true,
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ m := &IaaSClientMocked{
+ GetNetworkAreaRangeFails: tt.args.getNetworkAreaRangeFails,
+ GetNetworkAreaRangeResp: tt.args.getNetworkAreaRangeResp,
+ }
+ got, err := GetNetworkRangePrefix(context.Background(), m, "", "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetNetworkRangePrefix() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetNetworkRangePrefix() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetRouteFromAPIResponse(t *testing.T) {
+ type args struct {
+ prefix string
+ nexthop string
+ routes *[]iaas.Route
+ }
+ tests := []struct {
+ name string
+ args args
+ want iaas.Route
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ prefix: "1.1.1.0/24",
+ nexthop: "1.1.1.1",
+ routes: &[]iaas.Route{
+ {
+ 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"),
+ },
+ },
+ },
+ {
+ 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"),
+ },
+ },
+ },
+ {
+ 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{
+ 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"),
+ },
+ },
+ },
+ },
+ {
+ name: "not found",
+ args: args{
+ prefix: "1.1.1.0/24",
+ nexthop: "1.1.1.1",
+ routes: &[]iaas.Route{
+ {
+ 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{
+ NexthopIPv4: &iaas.NexthopIPv4{
+ Value: utils.Ptr("3.3.3.3"),
+ },
+ },
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty",
+ args: args{
+ prefix: "1.1.1.0/24",
+ nexthop: "1.1.1.1",
+ routes: &[]iaas.Route{},
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := GetRouteFromAPIResponse(tt.args.prefix, tt.args.nexthop, tt.args.routes)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetRouteFromAPIResponse() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("GetRouteFromAPIResponse() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetNetworkRangeFromAPIResponse(t *testing.T) {
+ type args struct {
+ prefix string
+ networkRanges *[]iaas.NetworkRange
+ }
+ tests := []struct {
+ name string
+ args args
+ want iaas.NetworkRange
+ wantErr bool
+ }{
+ {
+ name: "base",
+ args: args{
+ prefix: "1.1.1.0/24",
+ networkRanges: &[]iaas.NetworkRange{
+ {
+ Prefix: utils.Ptr("1.1.1.0/24"),
+ },
+ {
+ Prefix: utils.Ptr("2.2.2.0/24"),
+ },
+ {
+ Prefix: utils.Ptr("3.3.3.0/24"),
+ },
+ },
+ },
+ want: iaas.NetworkRange{
+ Prefix: utils.Ptr("1.1.1.0/24"),
+ },
+ },
+ {
+ name: "not found",
+ args: args{
+ prefix: "1.1.1.0/24",
+ networkRanges: &[]iaas.NetworkRange{
+ {
+ Prefix: utils.Ptr("2.2.2.0/24"),
+ },
+ {
+ Prefix: utils.Ptr("3.3.3.0/24"),
+ },
+ },
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty",
+ args: args{
+ prefix: "1.1.1.0/24",
+ networkRanges: &[]iaas.NetworkRange{},
+ },
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := GetNetworkRangeFromAPIResponse(tt.args.prefix, tt.args.networkRanges)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetNetworkRangeFromAPIResponse() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("GetNetworkRangeFromAPIResponse() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetImageName(t *testing.T) {
+ tests := []struct {
+ name string
+ imageResp *iaas.Image
+ imageErr bool
+ want string
+ wantErr bool
+ }{
+ {
+ name: "successful retrieval",
+ imageResp: &iaas.Image{Name: utils.Ptr("test-image")},
+ want: "test-image",
+ wantErr: false,
+ },
+ {
+ name: "error on retrieval",
+ imageErr: true,
+ wantErr: true,
+ },
+ {
+ name: "response is nil",
+ imageErr: false,
+ imageResp: nil,
+ want: "",
+ wantErr: true,
+ },
+ {
+ name: "name in response is nil",
+ imageErr: false,
+ imageResp: &iaas.Image{Name: nil},
+ want: "",
+ wantErr: true,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := &IaaSClientMocked{
+ GetImageFails: tt.imageErr,
+ GetImageResp: tt.imageResp,
+ }
+ got, err := GetImageName(context.Background(), client, "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetImageName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetImageName() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestGetAffinityGroupName(t *testing.T) {
+ tests := []struct {
+ name string
+ affinityResp *iaas.AffinityGroup
+ affinityErr bool
+ want string
+ wantErr bool
+ }{
+ {
+ name: "successful retrieval",
+ affinityResp: &iaas.AffinityGroup{Name: utils.Ptr("test-affinity")},
+ want: "test-affinity",
+ wantErr: false,
+ },
+ {
+ name: "error on retrieval",
+ affinityErr: true,
+ wantErr: true,
+ },
+ {
+ 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 {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ client := &IaaSClientMocked{
+ GetAffinityGroupsFails: tt.affinityErr,
+ GetAffinityGroupResp: tt.affinityResp,
+ }
+ got, err := GetAffinityGroupName(ctx, client, "", "", "")
+ if (err != nil) != tt.wantErr {
+ t.Errorf("GetAffinityGroupName() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("GetAffinityGroupName() = %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..efb8d0cfd
--- /dev/null
+++ b/internal/pkg/services/intake/client/client.go
@@ -0,0 +1,14 @@
+package client
+
+import (
+ "github.com/spf13/viper"
+ "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/stackitcloud/stackit-sdk-go/services/intake"
+)
+
+// 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 5a8aa31e4..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, sdkConfig.WithRegion("eu01"))
- }
-
- 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 1ad93ce18..1a5ba6076 100644
--- a/internal/pkg/services/load-balancer/utils/utils.go
+++ b/internal/pkg/services/load-balancer/utils/utils.go
@@ -15,23 +15,28 @@ const (
OP_FILTER_UNUSED
)
+// enforce implementation of interfaces
+var (
+ _ LoadBalancerClient = &loadbalancer.APIClient{}
+)
+
type LoadBalancerClient interface {
- GetCredentialsExecute(ctx context.Context, projectId, credentialsRef string) (*loadbalancer.GetCredentialsResponse, error)
- GetLoadBalancerExecute(ctx context.Context, projectId, name string) (*loadbalancer.LoadBalancer, error)
- UpdateTargetPool(ctx context.Context, projectId, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest
- ListLoadBalancersExecute(ctx context.Context, projectId string) (*loadbalancer.ListLoadBalancersResponse, error)
+ GetCredentialsExecute(ctx context.Context, projectId, region, credentialsRef string) (*loadbalancer.GetCredentialsResponse, error)
+ GetLoadBalancerExecute(ctx context.Context, projectId, region, name string) (*loadbalancer.LoadBalancer, error)
+ UpdateTargetPool(ctx context.Context, projectId, region, loadBalancerName, targetPoolName string) loadbalancer.ApiUpdateTargetPoolRequest
+ ListLoadBalancersExecute(ctx context.Context, projectId, region string) (*loadbalancer.ListLoadBalancersResponse, error)
}
-func GetCredentialsDisplayName(ctx context.Context, apiClient LoadBalancerClient, projectId, credentialsRef string) (string, error) {
- resp, err := apiClient.GetCredentialsExecute(ctx, projectId, credentialsRef)
+func GetCredentialsDisplayName(ctx context.Context, apiClient LoadBalancerClient, projectId, region, credentialsRef string) (string, error) {
+ resp, err := apiClient.GetCredentialsExecute(ctx, projectId, region, credentialsRef)
if err != nil {
return "", fmt.Errorf("get Load Balancer credentials: %w", err)
}
return *resp.Credential.DisplayName, nil
}
-func GetLoadBalancerTargetPool(ctx context.Context, apiClient LoadBalancerClient, projectId, loadBalancerName, targetPoolName string) (*loadbalancer.TargetPool, error) {
- resp, err := apiClient.GetLoadBalancerExecute(ctx, projectId, loadBalancerName)
+func GetLoadBalancerTargetPool(ctx context.Context, apiClient LoadBalancerClient, projectId, region, loadBalancerName, targetPoolName string) (*loadbalancer.TargetPool, error) {
+ resp, err := apiClient.GetLoadBalancerExecute(ctx, projectId, region, loadBalancerName)
if err != nil {
return nil, fmt.Errorf("get load balancer: %w", err)
}
@@ -121,8 +126,8 @@ func ToPayloadTargetPool(targetPool *loadbalancer.TargetPool) *loadbalancer.Upda
}
}
-func GetTargetName(ctx context.Context, apiClient LoadBalancerClient, projectId, loadBalancerName, targetPoolName, targetIp string) (string, error) {
- targetPool, err := GetLoadBalancerTargetPool(ctx, apiClient, projectId, loadBalancerName, targetPoolName)
+func GetTargetName(ctx context.Context, apiClient LoadBalancerClient, projectId, region, loadBalancerName, targetPoolName, targetIp string) (string, error) {
+ targetPool, err := GetLoadBalancerTargetPool(ctx, apiClient, projectId, region, loadBalancerName, targetPoolName)
if err != nil {
return "", fmt.Errorf("get target pool: %w", err)
}
@@ -142,10 +147,10 @@ func GetTargetName(ctx context.Context, apiClient LoadBalancerClient, projectId,
// GetUsedObsCredentials returns a list of credentials that are used by load balancers for observability metrics or logs.
// It goes through all load balancers and checks what observability credentials are being used, then returns a list of those credentials.
-func GetUsedObsCredentials(ctx context.Context, apiClient LoadBalancerClient, allCredentials []loadbalancer.CredentialsResponse, projectId string) ([]loadbalancer.CredentialsResponse, error) {
+func GetUsedObsCredentials(ctx context.Context, apiClient LoadBalancerClient, allCredentials []loadbalancer.CredentialsResponse, projectId, region string) ([]loadbalancer.CredentialsResponse, error) {
var usedCredentialsSlice []loadbalancer.CredentialsResponse
- loadBalancers, err := apiClient.ListLoadBalancersExecute(ctx, projectId)
+ loadBalancers, err := apiClient.ListLoadBalancersExecute(ctx, projectId, region)
if err != nil {
return nil, fmt.Errorf("list load balancers: %w", err)
}
@@ -154,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
}
@@ -218,7 +225,7 @@ func GetUnusedObsCredentials(usedCredentials, allCredentials []loadbalancer.Cred
// If unused is true, it returns only the credentials that are not used by any load balancer for observability metrics or logs.
// If both used and unused are true, it returns an error.
// If both used and unused are false, it returns the original list of credentials.
-func FilterCredentials(ctx context.Context, client LoadBalancerClient, allCredentials []loadbalancer.CredentialsResponse, projectId string, filterOp int) ([]loadbalancer.CredentialsResponse, error) {
+func FilterCredentials(ctx context.Context, client LoadBalancerClient, allCredentials []loadbalancer.CredentialsResponse, projectId, region string, filterOp int) ([]loadbalancer.CredentialsResponse, error) {
// check that filter OP is valid
if filterOp != OP_FILTER_USED && filterOp != OP_FILTER_UNUSED && filterOp != OP_FILTER_NOP {
return nil, fmt.Errorf("invalid filter operation")
@@ -228,7 +235,7 @@ func FilterCredentials(ctx context.Context, client LoadBalancerClient, allCreden
return allCredentials, nil
}
- usedCredentials, err := GetUsedObsCredentials(ctx, client, allCredentials, projectId)
+ usedCredentials, err := GetUsedObsCredentials(ctx, client, allCredentials, projectId, region)
if err != nil {
return nil, fmt.Errorf("get used observability credentials: %w", err)
}
diff --git a/internal/pkg/services/load-balancer/utils/utils_test.go b/internal/pkg/services/load-balancer/utils/utils_test.go
index 93b8f6ff3..5941b2c93 100644
--- a/internal/pkg/services/load-balancer/utils/utils_test.go
+++ b/internal/pkg/services/load-balancer/utils/utils_test.go
@@ -19,6 +19,7 @@ var (
)
const (
+ testRegion = "eu02"
testCredentialsRef = "credentials-ref"
testCredentialsDisplayName = "credentials-name"
testLoadBalancerName = "my-load-balancer"
@@ -33,29 +34,29 @@ type loadBalancerClientMocked struct {
listLoadBalancersResp *loadbalancer.ListLoadBalancersResponse
}
-func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _ string) (*loadbalancer.GetCredentialsResponse, error) {
+func (m *loadBalancerClientMocked) GetCredentialsExecute(_ context.Context, _, _, _ string) (*loadbalancer.GetCredentialsResponse, error) {
if m.getCredentialsFails {
return nil, fmt.Errorf("could not get credentials")
}
return m.getCredentialsResp, nil
}
-func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _ string) (*loadbalancer.LoadBalancer, error) {
+func (m *loadBalancerClientMocked) GetLoadBalancerExecute(_ context.Context, _, _, _ string) (*loadbalancer.LoadBalancer, error) {
if m.getLoadBalancerFails {
return nil, fmt.Errorf("could not get load balancer")
}
return m.getLoadBalancerResp, nil
}
-func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _ string) (*loadbalancer.ListLoadBalancersResponse, error) {
+func (m *loadBalancerClientMocked) ListLoadBalancersExecute(_ context.Context, _, _ string) (*loadbalancer.ListLoadBalancersResponse, error) {
if m.listLoadBalancersFails {
return nil, fmt.Errorf("could not list load balancers")
}
return m.listLoadBalancersResp, nil
}
-func (m *loadBalancerClientMocked) UpdateTargetPool(_ context.Context, _, _, _ string) loadbalancer.ApiUpdateTargetPoolRequest {
- return loadbalancer.ApiUpdateTargetPoolRequest{}
+func (m *loadBalancerClientMocked) UpdateTargetPool(_ context.Context, _, _, _, _ string) loadbalancer.ApiUpdateTargetPoolRequest {
+ return loadbalancer.UpdateTargetPoolRequest{}
}
func fixtureLoadBalancer(mods ...func(*loadbalancer.LoadBalancer)) *loadbalancer.LoadBalancer {
@@ -190,7 +191,7 @@ func TestGetCredentialsDisplayName(t *testing.T) {
getCredentialsResp: tt.getCredentialsResp,
}
- output, err := GetCredentialsDisplayName(context.Background(), client, testProjectId, testCredentialsRef)
+ output, err := GetCredentialsDisplayName(context.Background(), client, testProjectId, testRegion, testCredentialsRef)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -270,7 +271,7 @@ func TestGetLoadBalancerTargetPool(t *testing.T) {
getLoadBalancerResp: tt.getLoadBalancerResp,
}
- output, err := GetLoadBalancerTargetPool(context.Background(), client, testProjectId, testLoadBalancerName, tt.targetPoolName)
+ output, err := GetLoadBalancerTargetPool(context.Background(), client, testProjectId, testRegion, testLoadBalancerName, tt.targetPoolName)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -824,7 +825,7 @@ func TestGetTargetName(t *testing.T) {
getLoadBalancerResp: tt.getLoadBalancerResp,
}
- output, err := GetTargetName(context.Background(), client, testProjectId, testLoadBalancerName, tt.targetPoolName, tt.targetIp)
+ output, err := GetTargetName(context.Background(), client, testProjectId, testRegion, testLoadBalancerName, tt.targetPoolName, tt.targetIp)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -962,7 +963,7 @@ func TestGetUsedObsCredentials(t *testing.T) {
listLoadBalancersResp: tt.listLoadBalancersResp,
}
- output, err := GetUsedObsCredentials(testCtx, client, tt.allCredentials, testProjectId)
+ output, err := GetUsedObsCredentials(testCtx, client, tt.allCredentials, testProjectId, testRegion)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -1139,7 +1140,7 @@ func TestFilterCredentials(t *testing.T) {
listLoadBalancersResp: tt.listLoadBalancersResp,
listLoadBalancersFails: tt.listLoadBalancersFails,
}
- filteredCredentials, err := FilterCredentials(testCtx, client, tt.allCredentials, testProjectId, tt.filterOp)
+ filteredCredentials, err := FilterCredentials(testCtx, client, tt.allCredentials, testProjectId, testRegion, tt.filterOp)
if err != nil {
if !tt.isValid {
return
diff --git a/internal/pkg/services/logme/client/client.go b/internal/pkg/services/logme/client/client.go
index e99e46bf7..f65bcfb6a 100644
--- a/internal/pkg/services/logme/client/client.go
+++ b/internal/pkg/services/logme/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/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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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..6bce9b246
--- /dev/null
+++ b/internal/pkg/services/logs/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/stackitcloud/stackit-sdk-go/services/logs"
+
+ "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..4008db158
--- /dev/null
+++ b/internal/pkg/services/logs/utils/utils.go
@@ -0,0 +1,30 @@
+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)
+}
+
+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
+}
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..0c21b4d09
--- /dev/null
+++ b/internal/pkg/services/logs/utils/utils_test.go
@@ -0,0 +1,96 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/logs"
+
+ "github.com/google/uuid"
+)
+
+var (
+ testProjectId = uuid.NewString()
+ testInstanceId = uuid.NewString()
+)
+
+const (
+ testInstanceName = "instance"
+ testRegion = "eu01"
+)
+
+type logsClientMocked struct {
+ getInstanceFails bool
+ getInstanceResp *logs.LogsInstance
+}
+
+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 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)
+ }
+ })
+ }
+}
diff --git a/internal/pkg/services/mariadb/client/client.go b/internal/pkg/services/mariadb/client/client.go
index 4d4dbce7e..9952a324c 100644
--- a/internal/pkg/services/mariadb/client/client.go
+++ b/internal/pkg/services/mariadb/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/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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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 addcfe34a..58613728b 100644
--- a/internal/pkg/services/mongodbflex/client/client.go
+++ b/internal/pkg/services/mongodbflex/client/client.go
@@ -1,45 +1,13 @@
package client
import (
- "github.com/stackitcloud/stackit-cli/internal/pkg/auth"
+ "github.com/spf13/viper"
"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/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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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{}
- }
-
- 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..a5cbc7016 100644
--- a/internal/pkg/services/mongodbflex/utils/utils.go
+++ b/internal/pkg/services/mongodbflex/utils/utils.go
@@ -21,10 +21,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 +57,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 +101,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 +122,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 +144,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/object-storage/client/client.go b/internal/pkg/services/object-storage/client/client.go
index f1a3a2147..82b2447f8 100644
--- a/internal/pkg/services/object-storage/client/client.go
+++ b/internal/pkg/services/object-storage/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/objectstorage"
)
-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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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), true, 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 0f744d9da..bd23d0854 100644
--- a/internal/pkg/services/object-storage/utils/utils.go
+++ b/internal/pkg/services/object-storage/utils/utils.go
@@ -3,17 +3,35 @@ package utils
import (
"context"
"fmt"
+ "net/http"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/objectstorage"
)
type ObjectStorageClient interface {
- ListCredentialsGroupsExecute(ctx context.Context, projectId string) (*objectstorage.ListCredentialsGroupsResponse, error)
- ListAccessKeys(ctx context.Context, projectId string) objectstorage.ApiListAccessKeysRequest
+ 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 GetCredentialsGroupName(ctx context.Context, apiClient ObjectStorageClient, projectId, credentialsGroupId string) (string, error) {
- resp, err := apiClient.ListCredentialsGroupsExecute(ctx, projectId)
+func ProjectEnabled(ctx context.Context, apiClient ObjectStorageClient, projectId, region string) (bool, error) {
+ _, err := apiClient.GetServiceStatusExecute(ctx, projectId, region)
+ 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 {
+ return false, err
+ }
+ if oapiErr.StatusCode == http.StatusNotFound {
+ return false, nil
+ }
+ return false, err
+ }
+ return true, nil
+}
+
+func GetCredentialsGroupName(ctx context.Context, apiClient ObjectStorageClient, projectId, credentialsGroupId, region string) (string, error) {
+ resp, err := apiClient.ListCredentialsGroupsExecute(ctx, projectId, region)
if err != nil {
return "", fmt.Errorf("list Object Storage credentials groups: %w", err)
}
@@ -32,8 +50,8 @@ func GetCredentialsGroupName(ctx context.Context, apiClient ObjectStorageClient,
return "", fmt.Errorf("could not find Object Storage credentials group name")
}
-func GetCredentialsName(ctx context.Context, apiClient ObjectStorageClient, projectId, credentialsGroupId, keyId string) (string, error) {
- req := apiClient.ListAccessKeys(ctx, projectId)
+func GetCredentialsName(ctx context.Context, apiClient ObjectStorageClient, projectId, credentialsGroupId, keyId, region string) (string, error) {
+ req := apiClient.ListAccessKeys(ctx, projectId, region)
req = req.CredentialsGroup(credentialsGroupId)
resp, err := req.Execute()
diff --git a/internal/pkg/services/object-storage/utils/utils_test.go b/internal/pkg/services/object-storage/utils/utils_test.go
index 4fec25892..65b176172 100644
--- a/internal/pkg/services/object-storage/utils/utils_test.go
+++ b/internal/pkg/services/object-storage/utils/utils_test.go
@@ -12,6 +12,7 @@ import (
"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"
)
@@ -19,6 +20,7 @@ var (
testProjectId = uuid.NewString()
testCredentialsGroupId = uuid.NewString()
testCredentialsId = "credentialsID" //nolint:gosec // linter false positive
+ testRegion = "eu01"
)
const (
@@ -27,22 +29,86 @@ const (
)
type objectStorageClientMocked struct {
+ serviceDisabled bool
+ getServiceStatusFails bool
listCredentialsGroupsFails bool
listCredentialsGroupsResp *objectstorage.ListCredentialsGroupsResponse
listAccessKeysReq objectstorage.ApiListAccessKeysRequest
}
-func (m *objectStorageClientMocked) ListCredentialsGroupsExecute(_ context.Context, _ string) (*objectstorage.ListCredentialsGroupsResponse, error) {
+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
+}
+
+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 (m *objectStorageClientMocked) ListAccessKeys(_ context.Context, _ string) objectstorage.ApiListAccessKeysRequest {
+func (m *objectStorageClientMocked) ListAccessKeys(_ context.Context, _, _ string) objectstorage.ApiListAccessKeysRequest {
return m.listAccessKeysReq
}
+func TestProjectEnabled(t *testing.T) {
+ tests := []struct {
+ description string
+ serviceDisabled bool
+ getProjectFails bool
+ isValid bool
+ expectedOutput bool
+ }{
+ {
+ description: "project enabled",
+ isValid: true,
+ expectedOutput: true,
+ },
+ {
+ description: "project disabled (404)",
+ serviceDisabled: true,
+ isValid: true,
+ expectedOutput: false,
+ },
+ {
+ description: "get project fails",
+ getProjectFails: true,
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ client := &objectStorageClientMocked{
+ serviceDisabled: tt.serviceDisabled,
+ getServiceStatusFails: tt.getProjectFails,
+ }
+
+ output, err := ProjectEnabled(context.Background(), client, testProjectId, testRegion)
+
+ if tt.isValid && err != nil {
+ fmt.Printf("failed on valid input: %v", err)
+ t.Errorf("failed on valid input")
+ }
+ if !tt.isValid && err == nil {
+ t.Errorf("did not fail on invalid input")
+ }
+ if !tt.isValid {
+ return
+ }
+ if output != tt.expectedOutput {
+ t.Errorf("expected output to be %t, got %t", tt.expectedOutput, output)
+ }
+ })
+ }
+}
+
func TestGetCredentialsGroupName(t *testing.T) {
tests := []struct {
description string
@@ -137,7 +203,7 @@ func TestGetCredentialsGroupName(t *testing.T) {
listCredentialsGroupsResp: tt.listCredentialsGroupsResp,
}
- output, err := GetCredentialsGroupName(context.Background(), client, testProjectId, testCredentialsGroupId)
+ output, err := GetCredentialsGroupName(context.Background(), client, testProjectId, testCredentialsGroupId, testRegion)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -249,7 +315,7 @@ func TestGetCredentialsName(t *testing.T) {
t.Fatalf("Failed to marshal mocked response: %v", err)
}
- handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if tt.getCredentialsNameFails {
w.WriteHeader(http.StatusBadGateway)
@@ -276,7 +342,7 @@ func TestGetCredentialsName(t *testing.T) {
t.Fatalf("Failed to initialize client: %v", err)
}
- output, err := GetCredentialsName(context.Background(), client, testProjectId, testCredentialsGroupId, testCredentialsId)
+ output, err := GetCredentialsName(context.Background(), client, testProjectId, testCredentialsGroupId, testCredentialsId, testRegion)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
diff --git a/internal/pkg/services/observability/client/client.go b/internal/pkg/services/observability/client/client.go
new file mode 100644
index 000000000..83c496121
--- /dev/null
+++ b/internal/pkg/services/observability/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/stackitcloud/stackit-sdk-go/services/observability"
+
+ "github.com/spf13/viper"
+)
+
+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/argus/utils/utils.go b/internal/pkg/services/observability/utils/utils.go
similarity index 62%
rename from internal/pkg/services/argus/utils/utils.go
rename to internal/pkg/services/observability/utils/utils.go
index 556c3b7d2..234da09be 100644
--- a/internal/pkg/services/argus/utils/utils.go
+++ b/internal/pkg/services/observability/utils/utils.go
@@ -7,41 +7,40 @@ import (
"github.com/stackitcloud/stackit-cli/internal/pkg/errors"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
-
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
const (
- service = "argus"
+ service = "observability"
)
-type ArgusClient interface {
- GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error)
- GetGrafanaConfigsExecute(ctx context.Context, instanceId, projectId string) (*argus.GrafanaConfigs, error)
- UpdateGrafanaConfigs(ctx context.Context, instanceId string, projectId string) argus.ApiUpdateGrafanaConfigsRequest
+type ObservabilityClient interface {
+ GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*observability.GetInstanceResponse, error)
+ GetGrafanaConfigsExecute(ctx context.Context, instanceId, projectId string) (*observability.GrafanaConfigs, error)
+ UpdateGrafanaConfigs(ctx context.Context, instanceId string, projectId string) observability.ApiUpdateGrafanaConfigsRequest
}
var (
- defaultStaticConfigs = []argus.CreateScrapeConfigPayloadStaticConfigsInner{
+ defaultStaticConfigs = []observability.CreateScrapeConfigPayloadStaticConfigsInner{
{
Targets: utils.Ptr([]string{
"url-target",
}),
},
}
- DefaultCreateScrapeConfigPayload = argus.CreateScrapeConfigPayload{
+ 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),
}
)
-func ValidatePlanId(planId string, resp *argus.PlansResponse) error {
+func ValidatePlanId(planId string, resp *observability.PlansResponse) error {
if resp == nil {
- return fmt.Errorf("no Argus plans provided")
+ return fmt.Errorf("no Observability plans provided")
}
for i := range *resp.Plans {
@@ -51,16 +50,16 @@ func ValidatePlanId(planId string, resp *argus.PlansResponse) error {
}
}
- return &errors.ArgusInvalidPlanError{
+ return &errors.ObservabilityInvalidPlanError{
Service: service,
Details: fmt.Sprintf("You provided plan ID %q, which is invalid.", planId),
}
}
-func LoadPlanId(planName string, resp *argus.PlansResponse) (*string, error) {
+func LoadPlanId(planName string, resp *observability.PlansResponse) (*string, error) {
availablePlanNames := ""
if resp == nil {
- return nil, fmt.Errorf("no Argus plans provided")
+ return nil, fmt.Errorf("no Observability plans provided")
}
for i := range *resp.Plans {
@@ -75,15 +74,15 @@ func LoadPlanId(planName string, resp *argus.PlansResponse) (*string, error) {
}
details := fmt.Sprintf("You provided plan name %q, which is invalid. Available plan names are: %s", planName, availablePlanNames)
- return nil, &errors.ArgusInvalidPlanError{
+ return nil, &errors.ObservabilityInvalidPlanError{
Service: service,
Details: details,
}
}
-func MapToUpdateScrapeConfigPayload(resp *argus.GetScrapeConfigResponse) (*argus.UpdateScrapeConfigPayload, error) {
+func MapToUpdateScrapeConfigPayload(resp *observability.GetScrapeConfigResponse) (*observability.UpdateScrapeConfigPayload, error) {
if resp == nil || resp.Data == nil {
- return nil, fmt.Errorf("no Argus scrape config provided")
+ return nil, fmt.Errorf("no Observability scrape config provided")
}
data := resp.Data
@@ -98,7 +97,7 @@ func MapToUpdateScrapeConfigPayload(resp *argus.GetScrapeConfigResponse) (*argus
params = utils.Ptr(mapParams(*data.Params))
}
- payload := argus.UpdateScrapeConfigPayload{
+ payload := observability.UpdateScrapeConfigPayload{
BasicAuth: basicAuth,
BearerToken: data.BearerToken,
HonorLabels: data.HonorLabels,
@@ -107,28 +106,28 @@ func MapToUpdateScrapeConfigPayload(resp *argus.GetScrapeConfigResponse) (*argus
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,
TlsConfig: tlsConfig,
}
- if payload == (argus.UpdateScrapeConfigPayload{}) {
- return nil, fmt.Errorf("the provided Argus scrape config payload is empty")
+ if payload == (observability.UpdateScrapeConfigPayload{}) {
+ return nil, fmt.Errorf("the provided Observability scrape config payload is empty")
}
return &payload, nil
}
-func mapMetricsRelabelConfig(metricsRelabelConfigs *[]argus.MetricsRelabelConfig) *[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner {
+func mapMetricsRelabelConfig(metricsRelabelConfigs *[]observability.MetricsRelabelConfig) *[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner {
if metricsRelabelConfigs == nil {
return nil
}
- var mappedConfigs []argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner
+ var mappedConfigs []observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner
for _, config := range *metricsRelabelConfigs {
- mappedConfig := argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
- Action: config.Action,
+ mappedConfig := observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
+ Action: observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInnerGetActionAttributeType(config.Action),
Modulus: utils.ConvertInt64PToFloat64P(config.Modulus),
Regex: config.Regex,
Replacement: config.Replacement,
@@ -141,17 +140,17 @@ func mapMetricsRelabelConfig(metricsRelabelConfigs *[]argus.MetricsRelabelConfig
return &mappedConfigs
}
-func mapStaticConfig(staticConfigs *[]argus.StaticConfigs) *[]argus.UpdateScrapeConfigPayloadStaticConfigsInner {
+func mapStaticConfig(staticConfigs *[]observability.StaticConfigs) *[]observability.UpdateScrapeConfigPayloadStaticConfigsInner {
if staticConfigs == nil {
return nil
}
- var mappedConfigs []argus.UpdateScrapeConfigPayloadStaticConfigsInner
+ var mappedConfigs []observability.UpdateScrapeConfigPayloadStaticConfigsInner
for _, config := range *staticConfigs {
var labels *map[string]interface{}
if config.Labels != nil {
labels = utils.Ptr(mapStaticConfigLabels(*config.Labels))
}
- mappedConfig := argus.UpdateScrapeConfigPayloadStaticConfigsInner{
+ mappedConfig := observability.UpdateScrapeConfigPayloadStaticConfigsInner{
Labels: labels,
Targets: config.Targets,
}
@@ -161,23 +160,23 @@ func mapStaticConfig(staticConfigs *[]argus.StaticConfigs) *[]argus.UpdateScrape
return &mappedConfigs
}
-func mapBasicAuth(basicAuth *argus.BasicAuth) *argus.CreateScrapeConfigPayloadBasicAuth {
+func mapBasicAuth(basicAuth *observability.BasicAuth) *observability.CreateScrapeConfigPayloadBasicAuth {
if basicAuth == nil {
return nil
}
- return &argus.CreateScrapeConfigPayloadBasicAuth{
+ return &observability.CreateScrapeConfigPayloadBasicAuth{
Password: basicAuth.Password,
Username: basicAuth.Username,
}
}
-func mapTlsConfig(tlsConfig *argus.TLSConfig) *argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig {
+func mapTlsConfig(tlsConfig *observability.TLSConfig) *observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig {
if tlsConfig == nil {
return nil
}
- return &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{
+ return &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{
InsecureSkipVerify: tlsConfig.InsecureSkipVerify,
}
}
@@ -198,19 +197,19 @@ func mapStaticConfigLabels(labels map[string]string) map[string]interface{} {
return labelsMap
}
-func GetInstanceName(ctx context.Context, apiClient ArgusClient, instanceId, projectId string) (string, error) {
+func GetInstanceName(ctx context.Context, apiClient ObservabilityClient, instanceId, projectId string) (string, error) {
resp, err := apiClient.GetInstanceExecute(ctx, instanceId, projectId)
if err != nil {
- return "", fmt.Errorf("get Argus instance: %w", err)
+ return "", fmt.Errorf("get Observability instance: %w", err)
}
return *resp.Name, nil
}
-func ToPayloadGenericOAuth(respOAuth *argus.GrafanaOauth) *argus.UpdateGrafanaConfigsPayloadGenericOauth {
+func ToPayloadGenericOAuth(respOAuth *observability.GrafanaOauth) *observability.UpdateGrafanaConfigsPayloadGenericOauth {
if respOAuth == nil {
return nil
}
- return &argus.UpdateGrafanaConfigsPayloadGenericOauth{
+ return &observability.UpdateGrafanaConfigsPayloadGenericOauth{
ApiUrl: respOAuth.ApiUrl,
AuthUrl: respOAuth.AuthUrl,
Enabled: respOAuth.Enabled,
@@ -225,7 +224,7 @@ func ToPayloadGenericOAuth(respOAuth *argus.GrafanaOauth) *argus.UpdateGrafanaCo
}
}
-func GetPartialUpdateGrafanaConfigsPayload(ctx context.Context, apiClient ArgusClient, instanceId, projectId string, singleSignOn, publicReadAccess *bool) (*argus.UpdateGrafanaConfigsPayload, error) {
+func GetPartialUpdateGrafanaConfigsPayload(ctx context.Context, apiClient ObservabilityClient, instanceId, projectId string, singleSignOn, publicReadAccess *bool) (*observability.UpdateGrafanaConfigsPayload, error) {
currentConfigs, err := apiClient.GetGrafanaConfigsExecute(ctx, instanceId, projectId)
if err != nil {
return nil, fmt.Errorf("get current Grafana configs: %w", err)
@@ -234,7 +233,7 @@ func GetPartialUpdateGrafanaConfigsPayload(ctx context.Context, apiClient ArgusC
return nil, fmt.Errorf("no Grafana configs found for instance %q", instanceId)
}
- payload := &argus.UpdateGrafanaConfigsPayload{
+ payload := &observability.UpdateGrafanaConfigsPayload{
GenericOauth: ToPayloadGenericOAuth(currentConfigs.GenericOauth),
PublicReadAccess: currentConfigs.PublicReadAccess,
UseStackitSso: currentConfigs.UseStackitSso,
diff --git a/internal/pkg/services/argus/utils/utils_test.go b/internal/pkg/services/observability/utils/utils_test.go
similarity index 77%
rename from internal/pkg/services/argus/utils/utils_test.go
rename to internal/pkg/services/observability/utils/utils_test.go
index d36c572f3..0b5083aad 100644
--- a/internal/pkg/services/argus/utils/utils_test.go
+++ b/internal/pkg/services/observability/utils/utils_test.go
@@ -10,11 +10,11 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
- "github.com/stackitcloud/stackit-sdk-go/services/argus"
+ "github.com/stackitcloud/stackit-sdk-go/services/observability"
)
var (
- testClient = &argus.APIClient{}
+ testClient = &observability.APIClient{}
testProjectId = uuid.NewString()
testInstanceId = uuid.NewString()
testPlanId = uuid.NewString()
@@ -25,8 +25,8 @@ const (
testPlanName = "Plan-Name-01"
)
-var testPlansResponse = argus.PlansResponse{
- Plans: &[]argus.Plan{
+var testPlansResponse = observability.PlansResponse{
+ Plans: &[]observability.Plan{
{
Id: utils.Ptr(testPlanId),
Name: utils.Ptr(testPlanName),
@@ -34,11 +34,11 @@ var testPlansResponse = argus.PlansResponse{
},
}
-func fixtureGetScrapeConfigResponse(mods ...func(*argus.GetScrapeConfigResponse)) *argus.GetScrapeConfigResponse {
+func fixtureGetScrapeConfigResponse(mods ...func(*observability.GetScrapeConfigResponse)) *observability.GetScrapeConfigResponse {
number := int64(1)
- resp := &argus.GetScrapeConfigResponse{
- Data: &argus.Job{
- BasicAuth: &argus.BasicAuth{
+ resp := &observability.GetScrapeConfigResponse{
+ Data: &observability.Job{
+ BasicAuth: &observability.BasicAuth{
Username: utils.Ptr("username"),
Password: utils.Ptr("password"),
},
@@ -46,9 +46,9 @@ func fixtureGetScrapeConfigResponse(mods ...func(*argus.GetScrapeConfigResponse)
HonorLabels: utils.Ptr(true),
HonorTimeStamps: utils.Ptr(true),
MetricsPath: utils.Ptr("/metrics"),
- MetricsRelabelConfigs: &[]argus.MetricsRelabelConfig{
+ MetricsRelabelConfigs: &[]observability.MetricsRelabelConfig{
{
- Action: utils.Ptr("replace"),
+ Action: observability.METRICSRELABELCONFIGACTION_REPLACE.Ptr(),
Modulus: &number,
Regex: utils.Ptr("regex"),
Replacement: utils.Ptr("replacement"),
@@ -62,10 +62,10 @@ func fixtureGetScrapeConfigResponse(mods ...func(*argus.GetScrapeConfigResponse)
"key2": {},
},
SampleLimit: &number,
- Scheme: utils.Ptr("scheme"),
+ Scheme: observability.JOBSCHEME_HTTP.Ptr(),
ScrapeInterval: utils.Ptr("interval"),
ScrapeTimeout: utils.Ptr("timeout"),
- StaticConfigs: &[]argus.StaticConfigs{
+ StaticConfigs: &[]observability.StaticConfigs{
{
Labels: &map[string]string{
"label": "value",
@@ -74,7 +74,7 @@ func fixtureGetScrapeConfigResponse(mods ...func(*argus.GetScrapeConfigResponse)
Targets: &[]string{"target"},
},
},
- TlsConfig: &argus.TLSConfig{
+ TlsConfig: &observability.TLSConfig{
InsecureSkipVerify: utils.Ptr(true),
},
},
@@ -87,9 +87,9 @@ func fixtureGetScrapeConfigResponse(mods ...func(*argus.GetScrapeConfigResponse)
return resp
}
-func fixtureUpdateScrapeConfigPayload(mods ...func(*argus.UpdateScrapeConfigPayload)) *argus.UpdateScrapeConfigPayload {
- payload := &argus.UpdateScrapeConfigPayload{
- BasicAuth: &argus.CreateScrapeConfigPayloadBasicAuth{
+func fixtureUpdateScrapeConfigPayload(mods ...func(*observability.UpdateScrapeConfigPayload)) *observability.UpdateScrapeConfigPayload {
+ payload := &observability.UpdateScrapeConfigPayload{
+ BasicAuth: &observability.CreateScrapeConfigPayloadBasicAuth{
Username: utils.Ptr("username"),
Password: utils.Ptr("password"),
},
@@ -97,9 +97,9 @@ func fixtureUpdateScrapeConfigPayload(mods ...func(*argus.UpdateScrapeConfigPayl
HonorLabels: utils.Ptr(true),
HonorTimeStamps: utils.Ptr(true),
MetricsPath: utils.Ptr("/metrics"),
- MetricsRelabelConfigs: &[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
+ MetricsRelabelConfigs: &[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
{
- Action: utils.Ptr("replace"),
+ Action: utils.Ptr(observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInnerAction("replace")),
Modulus: utils.Ptr(1.0),
Regex: utils.Ptr("regex"),
Replacement: utils.Ptr("replacement"),
@@ -113,10 +113,10 @@ func fixtureUpdateScrapeConfigPayload(mods ...func(*argus.UpdateScrapeConfigPayl
"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: &[]argus.UpdateScrapeConfigPayloadStaticConfigsInner{
+ StaticConfigs: &[]observability.UpdateScrapeConfigPayloadStaticConfigsInner{
{
Labels: &map[string]interface{}{
"label": "value",
@@ -125,7 +125,7 @@ func fixtureUpdateScrapeConfigPayload(mods ...func(*argus.UpdateScrapeConfigPayl
Targets: &[]string{"target"},
},
},
- TlsConfig: &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{
+ TlsConfig: &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{
InsecureSkipVerify: utils.Ptr(true),
},
}
@@ -137,34 +137,34 @@ func fixtureUpdateScrapeConfigPayload(mods ...func(*argus.UpdateScrapeConfigPayl
return payload
}
-type argusClientMocked struct {
+type observabilityClientMocked struct {
getInstanceFails bool
- getInstanceResp *argus.GetInstanceResponse
+ getInstanceResp *observability.GetInstanceResponse
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
}
-func (m *argusClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*argus.GetInstanceResponse, error) {
+func (m *observabilityClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*observability.GetInstanceResponse, error) {
if m.getInstanceFails {
return nil, fmt.Errorf("could not get instance")
}
return m.getInstanceResp, nil
}
-func (m *argusClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*argus.GrafanaConfigs, error) {
+func (m *observabilityClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*observability.GrafanaConfigs, error) {
if m.getGrafanaConfigsFails {
return nil, fmt.Errorf("could not get grafana configs")
}
return m.getGrafanaConfigsResp, nil
}
-func (c *argusClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) argus.ApiUpdateGrafanaConfigsRequest {
+func (c *observabilityClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) observability.ApiUpdateGrafanaConfigsRequest {
return testClient.UpdateGrafanaConfigs(ctx, instanceId, projectId)
}
-func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.GrafanaConfigs {
- gc := argus.GrafanaConfigs{
- GenericOauth: &argus.GrafanaOauth{
+func fixtureGrafanaConfigs(mods ...func(gc *observability.GrafanaConfigs)) *observability.GrafanaConfigs {
+ gc := observability.GrafanaConfigs{
+ GenericOauth: &observability.GrafanaOauth{
ApiUrl: utils.Ptr("apiUrl"),
AuthUrl: utils.Ptr("authUrl"),
Enabled: utils.Ptr(true),
@@ -190,13 +190,13 @@ func TestGetInstanceName(t *testing.T) {
tests := []struct {
description string
getInstanceFails bool
- getInstanceResp *argus.GetInstanceResponse
+ getInstanceResp *observability.GetInstanceResponse
isValid bool
expectedOutput string
}{
{
description: "base",
- getInstanceResp: &argus.GetInstanceResponse{
+ getInstanceResp: &observability.GetInstanceResponse{
Name: utils.Ptr(testInstanceName),
},
isValid: true,
@@ -211,7 +211,7 @@ func TestGetInstanceName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- client := &argusClientMocked{
+ client := &observabilityClientMocked{
getInstanceFails: tt.getInstanceFails,
getInstanceResp: tt.getInstanceResp,
}
@@ -238,7 +238,7 @@ func TestLoadPlanId(t *testing.T) {
tests := []struct {
description string
planName string
- plansResponse *argus.PlansResponse
+ plansResponse *observability.PlansResponse
isValid bool
expectedOutput string
}{
@@ -275,8 +275,8 @@ func TestLoadPlanId(t *testing.T) {
{
description: "no available plans",
planName: testPlanName,
- plansResponse: &argus.PlansResponse{
- Plans: &[]argus.Plan{},
+ plansResponse: &observability.PlansResponse{
+ Plans: &[]observability.Plan{},
},
isValid: false,
},
@@ -306,7 +306,7 @@ func TestValidatePlanId(t *testing.T) {
tests := []struct {
description string
planId string
- plansResponse *argus.PlansResponse
+ plansResponse *observability.PlansResponse
isValid bool
}{
{
@@ -340,8 +340,8 @@ func TestValidatePlanId(t *testing.T) {
{
description: "no available plans",
planId: testPlanId,
- plansResponse: &argus.PlansResponse{
- Plans: &[]argus.Plan{},
+ plansResponse: &observability.PlansResponse{
+ Plans: &[]observability.Plan{},
},
isValid: false,
},
@@ -367,8 +367,8 @@ func TestValidatePlanId(t *testing.T) {
func TestMapToUpdateScrapeConfigPayload(t *testing.T) {
tests := []struct {
description string
- resp *argus.GetScrapeConfigResponse
- expectedPayload *argus.UpdateScrapeConfigPayload
+ resp *observability.GetScrapeConfigResponse
+ expectedPayload *observability.UpdateScrapeConfigPayload
isValid bool
}{
{
@@ -384,15 +384,15 @@ func TestMapToUpdateScrapeConfigPayload(t *testing.T) {
},
{
description: "nil data",
- resp: &argus.GetScrapeConfigResponse{
+ resp: &observability.GetScrapeConfigResponse{
Data: nil,
},
isValid: false,
},
{
description: "empty data",
- resp: &argus.GetScrapeConfigResponse{
- Data: &argus.Job{},
+ resp: &observability.GetScrapeConfigResponse{
+ Data: &observability.Job{},
},
isValid: false,
},
@@ -425,14 +425,14 @@ func TestMapToUpdateScrapeConfigPayload(t *testing.T) {
func TestMapMetricsRelabelConfig(t *testing.T) {
tests := []struct {
description string
- config *[]argus.MetricsRelabelConfig
- expected *[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner
+ config *[]observability.MetricsRelabelConfig
+ expected *[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner
}{
{
description: "base case",
- config: &[]argus.MetricsRelabelConfig{
+ 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: &[]argus.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
+ expected: &[]observability.CreateScrapeConfigPayloadMetricsRelabelConfigsInner{
{
- Action: utils.Ptr("replace"),
+ Action: observability.CREATESCRAPECONFIGPAYLOADMETRICSRELABELCONFIGSINNERACTION_REPLACE.Ptr(),
Modulus: utils.Float64Ptr(1.0),
Regex: utils.Ptr("regex"),
Replacement: utils.Ptr("replacement"),
@@ -455,7 +455,7 @@ func TestMapMetricsRelabelConfig(t *testing.T) {
},
{
description: "empty data",
- config: &[]argus.MetricsRelabelConfig{},
+ config: &[]observability.MetricsRelabelConfig{},
expected: nil,
},
{
@@ -484,12 +484,12 @@ func TestMapMetricsRelabelConfig(t *testing.T) {
func TestMapStaticConfig(t *testing.T) {
tests := []struct {
description string
- config *[]argus.StaticConfigs
- expected *[]argus.UpdateScrapeConfigPayloadStaticConfigsInner
+ config *[]observability.StaticConfigs
+ expected *[]observability.UpdateScrapeConfigPayloadStaticConfigsInner
}{
{
description: "base case",
- config: &[]argus.StaticConfigs{
+ config: &[]observability.StaticConfigs{
{
Labels: &map[string]string{
"label": "value",
@@ -498,7 +498,7 @@ func TestMapStaticConfig(t *testing.T) {
Targets: &[]string{"target", "target2"},
},
},
- expected: &[]argus.UpdateScrapeConfigPayloadStaticConfigsInner{
+ expected: &[]observability.UpdateScrapeConfigPayloadStaticConfigsInner{
{
Labels: utils.Ptr(map[string]interface{}{
"label": "value",
@@ -510,7 +510,7 @@ func TestMapStaticConfig(t *testing.T) {
},
{
description: "empty data",
- config: &[]argus.StaticConfigs{},
+ config: &[]observability.StaticConfigs{},
expected: nil,
},
{
@@ -539,24 +539,24 @@ func TestMapStaticConfig(t *testing.T) {
func TestMapBasicAuth(t *testing.T) {
tests := []struct {
description string
- auth *argus.BasicAuth
- expected *argus.CreateScrapeConfigPayloadBasicAuth
+ auth *observability.BasicAuth
+ expected *observability.CreateScrapeConfigPayloadBasicAuth
}{
{
description: "base case",
- auth: &argus.BasicAuth{
+ auth: &observability.BasicAuth{
Username: utils.Ptr("username"),
Password: utils.Ptr("password"),
},
- expected: &argus.CreateScrapeConfigPayloadBasicAuth{
+ expected: &observability.CreateScrapeConfigPayloadBasicAuth{
Username: utils.Ptr("username"),
Password: utils.Ptr("password"),
},
},
{
description: "empty data",
- auth: &argus.BasicAuth{},
- expected: &argus.CreateScrapeConfigPayloadBasicAuth{},
+ auth: &observability.BasicAuth{},
+ expected: &observability.CreateScrapeConfigPayloadBasicAuth{},
},
{
description: "nil",
@@ -584,22 +584,22 @@ func TestMapBasicAuth(t *testing.T) {
func TestMapTlsConfig(t *testing.T) {
tests := []struct {
description string
- config *argus.TLSConfig
- expected *argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig
+ config *observability.TLSConfig
+ expected *observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig
}{
{
description: "base case",
- config: &argus.TLSConfig{
+ config: &observability.TLSConfig{
InsecureSkipVerify: utils.Ptr(true),
},
- expected: &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{
+ expected: &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{
InsecureSkipVerify: utils.Ptr(true),
},
},
{
description: "empty data",
- config: &argus.TLSConfig{},
- expected: &argus.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{},
+ config: &observability.TLSConfig{},
+ expected: &observability.CreateScrapeConfigPayloadHttpSdConfigsInnerOauth2TlsConfig{},
},
{
description: "nil",
@@ -703,12 +703,12 @@ func TestMapStaticConfigLabels(t *testing.T) {
func TestToPayloadGenericOAuth(t *testing.T) {
tests := []struct {
description string
- response *argus.GrafanaOauth
- expected *argus.UpdateGrafanaConfigsPayloadGenericOauth
+ response *observability.GrafanaOauth
+ expected *observability.UpdateGrafanaConfigsPayloadGenericOauth
}{
{
description: "base",
- response: &argus.GrafanaOauth{
+ response: &observability.GrafanaOauth{
ApiUrl: utils.Ptr("apiUrl"),
AuthUrl: utils.Ptr("authUrl"),
Enabled: utils.Ptr(true),
@@ -721,7 +721,7 @@ func TestToPayloadGenericOAuth(t *testing.T) {
TokenUrl: utils.Ptr("tokenUrl"),
UsePkce: utils.Ptr(true),
},
- expected: &argus.UpdateGrafanaConfigsPayloadGenericOauth{
+ expected: &observability.UpdateGrafanaConfigsPayloadGenericOauth{
ApiUrl: utils.Ptr("apiUrl"),
AuthUrl: utils.Ptr("authUrl"),
Enabled: utils.Ptr(true),
@@ -760,9 +760,9 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
singleSignOn *bool
publicReadAccess *bool
getGrafanaConfigsFails bool
- getGrafanaConfigsResp *argus.GrafanaConfigs
+ getGrafanaConfigsResp *observability.GrafanaConfigs
isValid bool
- expectedPayload *argus.UpdateGrafanaConfigsPayload
+ expectedPayload *observability.UpdateGrafanaConfigsPayload
}{
{
description: "enable both",
@@ -770,7 +770,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
publicReadAccess: utils.Ptr(true),
getGrafanaConfigsResp: fixtureGrafanaConfigs(),
isValid: true,
- expectedPayload: &argus.UpdateGrafanaConfigsPayload{
+ expectedPayload: &observability.UpdateGrafanaConfigsPayload{
GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
UseStackitSso: utils.Ptr(true),
PublicReadAccess: utils.Ptr(true),
@@ -782,7 +782,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
publicReadAccess: utils.Ptr(false),
getGrafanaConfigsResp: fixtureGrafanaConfigs(),
isValid: true,
- expectedPayload: &argus.UpdateGrafanaConfigsPayload{
+ expectedPayload: &observability.UpdateGrafanaConfigsPayload{
GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
UseStackitSso: utils.Ptr(false),
PublicReadAccess: utils.Ptr(false),
@@ -794,7 +794,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
publicReadAccess: nil,
getGrafanaConfigsResp: fixtureGrafanaConfigs(),
isValid: true,
- expectedPayload: &argus.UpdateGrafanaConfigsPayload{
+ expectedPayload: &observability.UpdateGrafanaConfigsPayload{
GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
UseStackitSso: utils.Ptr(true),
PublicReadAccess: fixtureGrafanaConfigs().PublicReadAccess,
@@ -806,7 +806,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
publicReadAccess: utils.Ptr(true),
getGrafanaConfigsResp: fixtureGrafanaConfigs(),
isValid: true,
- expectedPayload: &argus.UpdateGrafanaConfigsPayload{
+ expectedPayload: &observability.UpdateGrafanaConfigsPayload{
GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
UseStackitSso: fixtureGrafanaConfigs().UseStackitSso,
PublicReadAccess: utils.Ptr(true),
@@ -816,11 +816,11 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
description: "disable single sign on",
singleSignOn: utils.Ptr(false),
publicReadAccess: nil,
- getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *argus.GrafanaConfigs) {
+ getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *observability.GrafanaConfigs) {
gc.UseStackitSso = utils.Ptr(true)
}),
isValid: true,
- expectedPayload: &argus.UpdateGrafanaConfigsPayload{
+ expectedPayload: &observability.UpdateGrafanaConfigsPayload{
GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
UseStackitSso: utils.Ptr(false),
PublicReadAccess: fixtureGrafanaConfigs().PublicReadAccess,
@@ -830,11 +830,11 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
description: "disable public read access",
singleSignOn: nil,
publicReadAccess: utils.Ptr(false),
- getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *argus.GrafanaConfigs) {
+ getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *observability.GrafanaConfigs) {
gc.PublicReadAccess = utils.Ptr(true)
}),
isValid: true,
- expectedPayload: &argus.UpdateGrafanaConfigsPayload{
+ expectedPayload: &observability.UpdateGrafanaConfigsPayload{
GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth),
UseStackitSso: fixtureGrafanaConfigs().UseStackitSso,
PublicReadAccess: utils.Ptr(false),
@@ -844,9 +844,9 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
description: "nil generic oauth",
singleSignOn: utils.Ptr(true),
publicReadAccess: utils.Ptr(true),
- getGrafanaConfigsResp: &argus.GrafanaConfigs{},
+ getGrafanaConfigsResp: &observability.GrafanaConfigs{},
isValid: true,
- expectedPayload: &argus.UpdateGrafanaConfigsPayload{
+ expectedPayload: &observability.UpdateGrafanaConfigsPayload{
GenericOauth: nil,
UseStackitSso: utils.Ptr(true),
PublicReadAccess: utils.Ptr(true),
@@ -870,7 +870,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) {
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
- client := &argusClientMocked{
+ client := &observabilityClientMocked{
getGrafanaConfigsFails: tt.getGrafanaConfigsFails,
getGrafanaConfigsResp: tt.getGrafanaConfigsResp,
}
diff --git a/internal/pkg/services/opensearch/client/client.go b/internal/pkg/services/opensearch/client/client.go
index b4036b37c..fb7d218a3 100644
--- a/internal/pkg/services/opensearch/client/client.go
+++ b/internal/pkg/services/opensearch/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/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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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 3698b1a46..d5b77761f 100644
--- a/internal/pkg/services/postgresflex/client/client.go
+++ b/internal/pkg/services/postgresflex/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/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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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/postgresflex/utils/utils.go b/internal/pkg/services/postgresflex/utils/utils.go
index 66bc48474..fc1965bdd 100644
--- a/internal/pkg/services/postgresflex/utils/utils.go
+++ b/internal/pkg/services/postgresflex/utils/utils.go
@@ -19,9 +19,9 @@ var instanceTypeToReplicas = map[string]int64{
}
type PostgresFlexClient interface {
- ListVersionsExecute(ctx context.Context, projectId string) (*postgresflex.ListVersionsResponse, error)
- GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*postgresflex.InstanceResponse, error)
- GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*postgresflex.GetUserResponse, error)
+ ListVersionsExecute(ctx context.Context, projectId, region string) (*postgresflex.ListVersionsResponse, error)
+ GetInstanceExecute(ctx context.Context, projectId, region, instanceId string) (*postgresflex.InstanceResponse, error)
+ GetUserExecute(ctx context.Context, projectId, region, instanceId, userId string) (*postgresflex.GetUserResponse, error)
}
func AvailableInstanceTypes() []string {
@@ -119,8 +119,8 @@ func LoadFlavorId(cpu, ram int64, flavors *[]postgresflex.Flavor) (*string, erro
}
}
-func GetLatestPostgreSQLVersion(ctx context.Context, apiClient PostgresFlexClient, projectId string) (string, error) {
- resp, err := apiClient.ListVersionsExecute(ctx, projectId)
+func GetLatestPostgreSQLVersion(ctx context.Context, apiClient PostgresFlexClient, projectId, region string) (string, error) {
+ resp, err := apiClient.ListVersionsExecute(ctx, projectId, region)
if err != nil {
return "", fmt.Errorf("get PostgreSQL versions: %w", err)
}
@@ -141,24 +141,24 @@ func GetLatestPostgreSQLVersion(ctx context.Context, apiClient PostgresFlexClien
return latestVersion, nil
}
-func GetInstanceName(ctx context.Context, apiClient PostgresFlexClient, projectId, instanceId string) (string, error) {
- resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId)
+func GetInstanceName(ctx context.Context, apiClient PostgresFlexClient, projectId, region, instanceId string) (string, error) {
+ resp, err := apiClient.GetInstanceExecute(ctx, projectId, region, instanceId)
if err != nil {
return "", fmt.Errorf("get PostgreSQL Flex instance: %w", err)
}
return *resp.Item.Name, nil
}
-func GetInstanceStatus(ctx context.Context, apiClient PostgresFlexClient, projectId, instanceId string) (string, error) {
- resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId)
+func GetInstanceStatus(ctx context.Context, apiClient PostgresFlexClient, projectId, region, instanceId string) (string, error) {
+ resp, err := apiClient.GetInstanceExecute(ctx, projectId, region, instanceId)
if err != nil {
return "", fmt.Errorf("get PostgreSQL Flex instance: %w", err)
}
return *resp.Item.Status, nil
}
-func GetUserName(ctx context.Context, apiClient PostgresFlexClient, projectId, instanceId, userId string) (string, error) {
- resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId)
+func GetUserName(ctx context.Context, apiClient PostgresFlexClient, projectId, region, instanceId, userId string) (string, error) {
+ resp, err := apiClient.GetUserExecute(ctx, projectId, region, instanceId, userId)
if err != nil {
return "", fmt.Errorf("get PostgreSQL Flex user: %w", err)
}
diff --git a/internal/pkg/services/postgresflex/utils/utils_test.go b/internal/pkg/services/postgresflex/utils/utils_test.go
index 250295e79..346debe49 100644
--- a/internal/pkg/services/postgresflex/utils/utils_test.go
+++ b/internal/pkg/services/postgresflex/utils/utils_test.go
@@ -22,6 +22,7 @@ const (
testInstanceName = "instance"
testUserName = "user"
testStatus = "running"
+ testRegion = "eu01"
)
type postgresFlexClientMocked struct {
@@ -33,21 +34,21 @@ type postgresFlexClientMocked struct {
getUserResp *postgresflex.GetUserResponse
}
-func (m *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*postgresflex.ListVersionsResponse, error) {
+func (m *postgresFlexClientMocked) ListVersionsExecute(_ context.Context, _, _ string) (*postgresflex.ListVersionsResponse, error) {
if m.listVersionsFails {
return nil, fmt.Errorf("could not list versions")
}
return m.listVersionsResp, nil
}
-func (m *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*postgresflex.InstanceResponse, error) {
+func (m *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _, _ string) (*postgresflex.InstanceResponse, error) {
if m.getInstanceFails {
return nil, fmt.Errorf("could not get instance")
}
return m.getInstanceResp, nil
}
-func (m *postgresFlexClientMocked) GetUserExecute(_ context.Context, _, _, _ string) (*postgresflex.GetUserResponse, error) {
+func (m *postgresFlexClientMocked) GetUserExecute(_ context.Context, _, _, _, _ string) (*postgresflex.GetUserResponse, error) {
if m.getUserFails {
return nil, fmt.Errorf("could not get user")
}
@@ -402,7 +403,7 @@ func TestGetLatestPostgreSQLVersion(t *testing.T) {
listVersionsResp: tt.listVersionsResp,
}
- output, err := GetLatestPostgreSQLVersion(context.Background(), client, testProjectId)
+ output, err := GetLatestPostgreSQLVersion(context.Background(), client, testProjectId, testRegion)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -452,7 +453,7 @@ func TestGetInstanceName(t *testing.T) {
getInstanceResp: tt.getInstanceResp,
}
- output, err := GetInstanceName(context.Background(), client, testProjectId, testInstanceId)
+ output, err := GetInstanceName(context.Background(), client, testProjectId, testRegion, testInstanceId)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -502,7 +503,7 @@ func TestGetInstanceStatus(t *testing.T) {
getInstanceResp: tt.getInstanceResp,
}
- output, err := GetInstanceStatus(context.Background(), client, testProjectId, testInstanceId)
+ output, err := GetInstanceStatus(context.Background(), client, testProjectId, testRegion, testInstanceId)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -552,7 +553,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, testRegion, testInstanceId, testUserId)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
diff --git a/internal/pkg/services/rabbitmq/client/client.go b/internal/pkg/services/rabbitmq/client/client.go
index 821037064..df717b305 100644
--- a/internal/pkg/services/rabbitmq/client/client.go
+++ b/internal/pkg/services/rabbitmq/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/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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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 90e523c85..72c023398 100644
--- a/internal/pkg/services/redis/client/client.go
+++ b/internal/pkg/services/redis/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/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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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.go b/internal/pkg/services/resourcemanager/utils/utils.go
new file mode 100644
index 000000000..bf8724b06
--- /dev/null
+++ b/internal/pkg/services/resourcemanager/utils/utils.go
@@ -0,0 +1,32 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager"
+)
+
+type ResourceManagerClient interface {
+ GetOrganizationExecute(ctx context.Context, organizationId string) (*resourcemanager.OrganizationResponse, error)
+ GetProjectExecute(ctx context.Context, projectId string) (*resourcemanager.GetProjectResponse, error)
+}
+
+// GetOrganizationName returns the name of an organization by its ID.
+func GetOrganizationName(ctx context.Context, apiClient ResourceManagerClient, orgId string) (string, error) {
+ resp, err := apiClient.GetOrganizationExecute(ctx, orgId)
+ if err != nil {
+ return "", fmt.Errorf("get organization details: %w", err)
+ }
+
+ return *resp.Name, nil
+}
+
+func GetProjectName(ctx context.Context, apiClient ResourceManagerClient, projectId string) (string, error) {
+ resp, err := apiClient.GetProjectExecute(ctx, projectId)
+ if err != nil {
+ return "", fmt.Errorf("get project details: %w", err)
+ }
+
+ return *resp.Name, nil
+}
diff --git a/internal/pkg/services/resourcemanager/utils/utils_test.go b/internal/pkg/services/resourcemanager/utils/utils_test.go
new file mode 100644
index 000000000..bcd0ad2d0
--- /dev/null
+++ b/internal/pkg/services/resourcemanager/utils/utils_test.go
@@ -0,0 +1,136 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager"
+)
+
+var (
+ testOrgId = uuid.NewString()
+)
+
+const (
+ testOrgName = "organization"
+)
+
+type resourceManagerClientMocked struct {
+ getOrganizationFails bool
+ getOrganizationResp *resourcemanager.OrganizationResponse
+ getProjectFails bool
+ getProjectResp *resourcemanager.GetProjectResponse
+}
+
+func (s *resourceManagerClientMocked) GetOrganizationExecute(_ context.Context, _ string) (*resourcemanager.OrganizationResponse, error) {
+ if s.getOrganizationFails {
+ return nil, fmt.Errorf("could not get organization")
+ }
+ return s.getOrganizationResp, nil
+}
+
+func (s *resourceManagerClientMocked) GetProjectExecute(_ context.Context, _ string) (*resourcemanager.GetProjectResponse, error) {
+ if s.getProjectFails {
+ return nil, fmt.Errorf("could not get project")
+ }
+ return s.getProjectResp, nil
+}
+
+func TestGetOrganizationName(t *testing.T) {
+ tests := []struct {
+ description string
+ getOrganizationFails bool
+ getOrganizationResp *resourcemanager.OrganizationResponse
+ isValid bool
+ expectedOutput string
+ }{
+ {
+ description: "base",
+ getOrganizationResp: &resourcemanager.OrganizationResponse{
+ Name: utils.Ptr(testOrgName),
+ },
+ isValid: true,
+ expectedOutput: testOrgName,
+ },
+ {
+ description: "get organization fails",
+ getOrganizationFails: true,
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ client := &resourceManagerClientMocked{
+ getOrganizationFails: tt.getOrganizationFails,
+ getOrganizationResp: tt.getOrganizationResp,
+ }
+
+ output, err := GetOrganizationName(context.Background(), client, testOrgId)
+
+ 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 TestGetProjectName(t *testing.T) {
+ tests := []struct {
+ description string
+ getProjectFails bool
+ getProjectResp *resourcemanager.GetProjectResponse
+ isValid bool
+ expectedOutput string
+ }{
+ {
+ description: "base",
+ getProjectResp: &resourcemanager.GetProjectResponse{
+ Name: utils.Ptr("project"),
+ },
+ isValid: true,
+ expectedOutput: "project",
+ },
+ {
+ description: "get project fails",
+ getProjectFails: true,
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ client := &resourceManagerClientMocked{
+ getProjectFails: tt.getProjectFails,
+ getProjectResp: tt.getProjectResp,
+ }
+
+ output, err := GetProjectName(context.Background(), client, testOrgId)
+
+ 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/runcommand/client/client.go b/internal/pkg/services/runcommand/client/client.go
new file mode 100644
index 000000000..1ecb49f4f
--- /dev/null
+++ b/internal/pkg/services/runcommand/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/runcommand"
+)
+
+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
new file mode 100644
index 000000000..d6775f373
--- /dev/null
+++ b/internal/pkg/services/runcommand/utils/utils.go
@@ -0,0 +1,25 @@
+package utils
+
+import (
+ "os"
+ "strings"
+)
+
+func ParseScriptParams(params map[string]string) (map[string]string, error) {
+ if params == nil {
+ return nil, nil
+ }
+ parsed := map[string]string{}
+ 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])
+ if err != nil {
+ return nil, err
+ }
+ parsed[k] = string(fileContents)
+ }
+ }
+ return parsed, nil
+}
diff --git a/internal/pkg/services/runcommand/utils/utils_test.go b/internal/pkg/services/runcommand/utils/utils_test.go
new file mode 100644
index 000000000..5b1d1c69f
--- /dev/null
+++ b/internal/pkg/services/runcommand/utils/utils_test.go
@@ -0,0 +1,46 @@
+package utils
+
+import (
+ "testing"
+)
+
+func TestParseScriptParams(t *testing.T) {
+ tests := []struct {
+ description string
+ input map[string]string
+ expectedOutput map[string]string
+ isValid bool
+ }{
+ {
+ "base-ok",
+ map[string]string{"script": "ls /"},
+ map[string]string{"script": "ls /"},
+ true,
+ },
+ {
+ "not-ok-nonexistant-file-specified-for-script",
+ map[string]string{"script": "@{/some/file/which/does/not/exist/and/thus/fails}"},
+ nil,
+ false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ output, err := ParseScriptParams(tt.input)
+
+ 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["script"] != tt.expectedOutput["script"] {
+ t.Errorf("expected output to be %s, got %s", tt.expectedOutput["script"], output["script"])
+ }
+ })
+ }
+}
diff --git a/internal/pkg/services/secrets-manager/client/client.go b/internal/pkg/services/secrets-manager/client/client.go
index e6aa7f2b5..dfedcb0e3 100644
--- a/internal/pkg/services/secrets-manager/client/client.go
+++ b/internal/pkg/services/secrets-manager/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/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{}
- }
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
-
- 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/serverbackup/client/client.go b/internal/pkg/services/serverbackup/client/client.go
new file mode 100644
index 000000000..c8726b392
--- /dev/null
+++ b/internal/pkg/services/serverbackup/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/serverbackup"
+)
+
+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.go b/internal/pkg/services/serverbackup/utils/utils.go
new file mode 100644
index 000000000..c6974a414
--- /dev/null
+++ b/internal/pkg/services/serverbackup/utils/utils.go
@@ -0,0 +1,34 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+type ServerBackupClient interface {
+ ListBackupSchedulesExecute(ctx context.Context, projectId, serverId, region string) (*serverbackup.GetBackupSchedulesResponse, error)
+ ListBackupsExecute(ctx context.Context, projectId, serverId, region string) (*serverbackup.GetBackupsListResponse, error)
+}
+
+func CanDisableBackupService(ctx context.Context, client ServerBackupClient, projectId, serverId, region string) (bool, error) {
+ schedules, err := client.ListBackupSchedulesExecute(ctx, projectId, serverId, region)
+ if err != nil {
+ return false, fmt.Errorf("list backup schedules: %w", err)
+ }
+ if *schedules.Items != nil && len(*schedules.Items) > 0 {
+ return false, nil
+ }
+
+ backups, err := client.ListBackupsExecute(ctx, projectId, serverId, region)
+ if err != nil {
+ return false, fmt.Errorf("list backups: %w", err)
+ }
+ if *backups.Items != nil && len(*backups.Items) > 0 {
+ return false, nil
+ }
+
+ // no backups and no backup schedules found for this server => can disable backup service
+ return true, nil
+}
diff --git a/internal/pkg/services/serverbackup/utils/utils_test.go b/internal/pkg/services/serverbackup/utils/utils_test.go
new file mode 100644
index 000000000..7262fdd6e
--- /dev/null
+++ b/internal/pkg/services/serverbackup/utils/utils_test.go
@@ -0,0 +1,146 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/services/serverbackup"
+)
+
+var (
+ testProjectId = uuid.NewString()
+ testServerId = uuid.NewString()
+ testRegion = "eu01"
+)
+
+type serverbackupClientMocked struct {
+ listBackupSchedulesFails bool
+ listBackupSchedulesResp *serverbackup.GetBackupSchedulesResponse
+ listBackupsFails bool
+ listBackupsResp *serverbackup.GetBackupsListResponse
+}
+
+func (m *serverbackupClientMocked) ListBackupSchedulesExecute(_ context.Context, _, _, _ string) (*serverbackup.GetBackupSchedulesResponse, error) {
+ if m.listBackupSchedulesFails {
+ return nil, fmt.Errorf("could not list backup schedules")
+ }
+ return m.listBackupSchedulesResp, nil
+}
+
+func (m *serverbackupClientMocked) ListBackupsExecute(_ context.Context, _, _, _ string) (*serverbackup.GetBackupsListResponse, error) {
+ if m.listBackupsFails {
+ return nil, fmt.Errorf("could not list backups")
+ }
+ return m.listBackupsResp, nil
+}
+
+func TestCanDisableBackupService(t *testing.T) {
+ tests := []struct {
+ description string
+ listBackupsFails bool
+ listBackupSchedulesFails bool
+ listBackups *serverbackup.GetBackupsListResponse
+ listBackupSchedules *serverbackup.GetBackupSchedulesResponse
+ isValid bool // isValid ==> err == nil
+ expectedOutput bool // expectedCanDisable
+ }{
+ {
+ description: "base-ok-can-disable-backups-service-no-backups-no-backup-schedules",
+ listBackupsFails: false,
+ listBackupSchedulesFails: false,
+ listBackups: &serverbackup.GetBackupsListResponse{Items: &[]serverbackup.Backup{}},
+ listBackupSchedules: &serverbackup.GetBackupSchedulesResponse{Items: &[]serverbackup.BackupSchedule{}},
+ isValid: true,
+ expectedOutput: true,
+ },
+ {
+ description: "not-ok-api-error-list-backups",
+ listBackupsFails: true,
+ listBackupSchedulesFails: false,
+ listBackups: &serverbackup.GetBackupsListResponse{Items: &[]serverbackup.Backup{}},
+ listBackupSchedules: &serverbackup.GetBackupSchedulesResponse{Items: &[]serverbackup.BackupSchedule{}},
+ isValid: false,
+ expectedOutput: false,
+ },
+ {
+ description: "not-ok-api-error-list-backup-schedules",
+ listBackupsFails: true,
+ listBackupSchedulesFails: false,
+ listBackups: &serverbackup.GetBackupsListResponse{Items: &[]serverbackup.Backup{}},
+ listBackupSchedules: &serverbackup.GetBackupSchedulesResponse{Items: &[]serverbackup.BackupSchedule{}},
+ isValid: false,
+ expectedOutput: false,
+ },
+ {
+ description: "not-ok-has-backups-cannot-disable",
+ listBackupsFails: false,
+ listBackupSchedulesFails: false,
+ listBackups: &serverbackup.GetBackupsListResponse{
+ Items: &[]serverbackup.Backup{
+ {
+ CreatedAt: utils.Ptr("test timestamp"),
+ ExpireAt: utils.Ptr("test timestamp"),
+ Id: utils.Ptr("5"),
+ LastRestoredAt: utils.Ptr("test timestamp"),
+ Name: utils.Ptr("test name"),
+ Size: utils.Ptr(int64(5)),
+ Status: serverbackup.BACKUPSTATUS_IN_PROGRESS.Ptr(),
+ VolumeBackups: nil,
+ },
+ },
+ },
+ listBackupSchedules: &serverbackup.GetBackupSchedulesResponse{Items: &[]serverbackup.BackupSchedule{}},
+ isValid: true,
+ expectedOutput: false,
+ },
+ {
+ description: "not-ok-has-backups-schedules-cannot-disable",
+ listBackupsFails: false,
+ listBackupSchedulesFails: false,
+ listBackups: &serverbackup.GetBackupsListResponse{Items: &[]serverbackup.Backup{}},
+ listBackupSchedules: &serverbackup.GetBackupSchedulesResponse{
+ Items: &[]serverbackup.BackupSchedule{
+ {
+ BackupProperties: nil,
+ Enabled: utils.Ptr(false),
+ Id: utils.Ptr(int64(5)),
+ Name: utils.Ptr("some name"),
+ Rrule: utils.Ptr("some rrule"),
+ },
+ },
+ },
+ isValid: true,
+ expectedOutput: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ client := &serverbackupClientMocked{
+ listBackupsFails: tt.listBackupsFails,
+ listBackupSchedulesFails: tt.listBackupSchedulesFails,
+ listBackupsResp: tt.listBackups,
+ listBackupSchedulesResp: tt.listBackupSchedules,
+ }
+
+ output, err := CanDisableBackupService(context.Background(), client, testProjectId, testServerId, 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 %t, got %t", tt.expectedOutput, output)
+ }
+ })
+ }
+}
diff --git a/internal/pkg/services/serverosupdate/client/client.go b/internal/pkg/services/serverosupdate/client/client.go
new file mode 100644
index 000000000..a3d324d90
--- /dev/null
+++ b/internal/pkg/services/serverosupdate/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/serverupdate"
+)
+
+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
new file mode 100644
index 000000000..e0bced744
--- /dev/null
+++ b/internal/pkg/services/service-enablement/client/client.go
@@ -0,0 +1,14 @@
+package client
+
+import (
+ "github.com/spf13/viper"
+ "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/stackitcloud/stackit-sdk-go/services/serviceenablement"
+)
+
+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
new file mode 100644
index 000000000..5f1976164
--- /dev/null
+++ b/internal/pkg/services/service-enablement/utils/utils.go
@@ -0,0 +1,32 @@
+package utils
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement"
+)
+
+const (
+ SKEServiceId = "cloud.stackit.ske"
+)
+
+type ServiceEnablementClient interface {
+ GetServiceStatusRegionalExecute(ctx context.Context, region, projectId, serviceId string) (*serviceenablement.ServiceStatus, error)
+}
+
+func ProjectEnabled(ctx context.Context, apiClient ServiceEnablementClient, projectId, region string) (bool, error) {
+ project, err := apiClient.GetServiceStatusRegionalExecute(ctx, region, projectId, SKEServiceId)
+ 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 {
+ return false, err
+ }
+ if oapiErr.StatusCode == http.StatusNotFound {
+ return false, nil
+ }
+ return false, err
+ }
+ 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
new file mode 100644
index 000000000..b898adb69
--- /dev/null
+++ b/internal/pkg/services/service-enablement/utils/utils_test.go
@@ -0,0 +1,104 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement"
+)
+
+var (
+ testProjectId = uuid.NewString()
+ testRegion = "eu01"
+)
+
+type serviceEnableClientMocked struct {
+ serviceDisabled bool
+ getServiceStatusFails bool
+ getServiceStatusResp *serviceenablement.ServiceStatus
+}
+
+func (m *serviceEnableClientMocked) GetServiceStatusRegionalExecute(_ context.Context, _, _, _ string) (*serviceenablement.ServiceStatus, error) {
+ if m.getServiceStatusFails {
+ return nil, fmt.Errorf("could not get service status")
+ }
+ if m.serviceDisabled {
+ return nil, &oapierror.GenericOpenAPIError{StatusCode: 404}
+ }
+ return m.getServiceStatusResp, nil
+}
+
+func TestProjectEnabled(t *testing.T) {
+ tests := []struct {
+ description string
+ serviceDisabled bool
+ getProjectFails bool
+ getProjectResp *serviceenablement.ServiceStatus
+ isValid bool
+ expectedOutput bool
+ }{
+ {
+ description: "project enabled",
+ getProjectResp: &serviceenablement.ServiceStatus{State: serviceenablement.SERVICESTATUSSTATE_ENABLED.Ptr()},
+ isValid: true,
+ expectedOutput: true,
+ },
+ {
+ description: "project disabled (404)",
+ serviceDisabled: true,
+ isValid: true,
+ expectedOutput: false,
+ },
+ {
+ description: "project disabled 1",
+ getProjectResp: &serviceenablement.ServiceStatus{State: serviceenablement.SERVICESTATUSSTATE_ENABLING.Ptr()},
+ isValid: true,
+ expectedOutput: false,
+ },
+ {
+ description: "project disabled 2",
+ getProjectResp: &serviceenablement.ServiceStatus{State: serviceenablement.SERVICESTATUSSTATE_DISABLING.Ptr()},
+ isValid: true,
+ expectedOutput: false,
+ },
+ {
+ description: "project disabled 3",
+ getProjectResp: &serviceenablement.ServiceStatus{State: serviceenablement.SERVICESTATUSSTATE_DISABLING.Ptr()},
+ isValid: true,
+ expectedOutput: false,
+ },
+ {
+ description: "get clusters fails",
+ getProjectFails: true,
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ client := &serviceEnableClientMocked{
+ serviceDisabled: tt.serviceDisabled,
+ getServiceStatusFails: tt.getProjectFails,
+ getServiceStatusResp: tt.getProjectResp,
+ }
+
+ output, err := ProjectEnabled(context.Background(), client, testRegion, testProjectId)
+
+ if tt.isValid && err != nil {
+ t.Errorf("failed on valid input")
+ }
+ if !tt.isValid && err == nil {
+ t.Errorf("did not fail on invalid input")
+ }
+ if !tt.isValid {
+ return
+ }
+ if output != tt.expectedOutput {
+ t.Errorf("expected output to be %t, got %t", tt.expectedOutput, output)
+ }
+ })
+ }
+}
diff --git a/internal/pkg/services/sfs/client/client.go b/internal/pkg/services/sfs/client/client.go
new file mode 100644
index 000000000..3dc2ef801
--- /dev/null
+++ b/internal/pkg/services/sfs/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/stackitcloud/stackit-sdk-go/services/sfs"
+
+ "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..de4dbe11f
--- /dev/null
+++ b/internal/pkg/services/sfs/utils/utils_test.go
@@ -0,0 +1,206 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "github.com/stackitcloud/stackit-sdk-go/services/sfs"
+)
+
+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 36175a964..5b4b69f38 100644
--- a/internal/pkg/services/ske/client/client.go
+++ b/internal/pkg/services/ske/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/ske"
)
-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 {
- cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01"))
- }
-
- 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 901e961c3..904ff97a1 100644
--- a/internal/pkg/services/ske/utils/utils.go
+++ b/internal/pkg/services/ske/utils/utils.go
@@ -3,23 +3,23 @@ package utils
import (
"context"
"fmt"
+ "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"
"golang.org/x/mod/semver"
)
const (
- defaultNodepoolAvailabilityZone = "eu01-3"
- defaultNodepoolCRI = "containerd"
- defaultNodepoolMachineType = "b1.2"
+ defaultNodepoolCRI = ske.CRINAME_CONTAINERD
defaultNodepoolMachineImageName = "flatcar"
- defaultNodepoolMaxSurge = 1
- defaultNodepoolMaximum = 2
+ defaultNodepoolMaxUnavailable = 0
defaultNodepoolMinimum = 1
defaultNodepoolName = "pool-default"
defaultNodepoolVolumeType = "storage_premium_perf2"
@@ -29,21 +29,12 @@ const (
)
type SKEClient interface {
- GetServiceStatusExecute(ctx context.Context, projectId string) (*ske.ProjectResponse, error)
- ListClustersExecute(ctx context.Context, projectId string) (*ske.ListClustersResponse, error)
- ListProviderOptionsExecute(ctx context.Context) (*ske.ProviderOptions, error)
+ ListClustersExecute(ctx context.Context, projectId, region string) (*ske.ListClustersResponse, error)
+ ListProviderOptionsExecute(ctx context.Context, region string) (*ske.ProviderOptions, error)
}
-func ProjectEnabled(ctx context.Context, apiClient SKEClient, projectId string) (bool, error) {
- project, err := apiClient.GetServiceStatusExecute(ctx, projectId)
- if err != nil {
- return false, fmt.Errorf("get SKE status: %w", err)
- }
- return *project.State == ske.PROJECTSTATE_CREATED, nil
-}
-
-func ClusterExists(ctx context.Context, apiClient SKEClient, projectId, clusterName string) (bool, error) {
- clusters, err := apiClient.ListClustersExecute(ctx, projectId)
+func ClusterExists(ctx context.Context, apiClient SKEClient, projectId, region, clusterName string) (bool, error) {
+ clusters, err := apiClient.ListClustersExecute(ctx, projectId, region)
if err != nil {
return false, fmt.Errorf("list SKE clusters: %w", err)
}
@@ -55,8 +46,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 SKEClient, region string) (*ske.CreateOrUpdateClusterPayload, error) {
+ resp, err := apiClient.ListProviderOptionsExecute(ctx, region)
if err != nil {
return nil, fmt.Errorf("get SKE provider options: %w", err)
}
@@ -116,23 +107,40 @@ func getDefaultPayloadKubernetes(resp *ske.ProviderOptions) (*ske.Kubernetes, er
}
func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) {
+ if resp.AvailabilityZones == nil || 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 resp.MachineTypes == nil || len(*resp.MachineTypes) == 0 {
+ return nil, fmt.Errorf("no machine types found")
+ }
+ 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),
+ Type: &machineType,
Image: &ske.Image{
Name: utils.Ptr(defaultNodepoolMachineImageName),
},
},
- MaxSurge: utils.Ptr(int64(defaultNodepoolMaxSurge)),
- Maximum: utils.Ptr(int64(defaultNodepoolMaximum)),
- Minimum: utils.Ptr(int64(defaultNodepoolMinimum)),
- Name: utils.Ptr(defaultNodepoolName),
+ // there must be as many nodes as availability zones are given
+ MaxSurge: utils.Ptr(int64(len(availabilityZones))),
+ MaxUnavailable: utils.Ptr(int64(defaultNodepoolMaxUnavailable)),
+ Maximum: utils.Ptr(int64(len(availabilityZones))),
+ Minimum: utils.Ptr(int64(defaultNodepoolMinimum)),
+ Name: utils.Ptr(defaultNodepoolName),
Volume: &ske.Volume{
Type: utils.Ptr(defaultNodepoolVolumeType),
Size: utils.Ptr(int64(defaultNodepoolVolumeSize)),
@@ -235,6 +243,39 @@ func ConvertToSeconds(timeStr string) (*string, error) {
return utils.Ptr(strconv.FormatUint(result, 10)), nil
}
+// Merge new Kubeconfig into existing Kubeconfig. If it doesn´t exits, creates a new one
+func MergeKubeConfig(pathDestionationKubeConfig, contentNewKubeConfig string) error {
+ if contentNewKubeConfig == "" {
+ return fmt.Errorf("no data to merge. the new kubeconfig is empty")
+ }
+
+ newConfig, err := clientcmd.Load([]byte(contentNewKubeConfig))
+ if err != nil {
+ return fmt.Errorf("error loading new kubeconfig: %w", err)
+ }
+
+ // if the destionation kubeconfig does not exist, create a new one
+ if _, err := os.Stat(pathDestionationKubeConfig); os.IsNotExist(err) {
+ return WriteConfigFile(pathDestionationKubeConfig, contentNewKubeConfig)
+ }
+
+ existingConfig, err := clientcmd.LoadFromFile(pathDestionationKubeConfig)
+ if err != nil {
+ return fmt.Errorf("error loading existing kubeconfig: %w", err)
+ }
+
+ maps.Copy(existingConfig.AuthInfos, newConfig.AuthInfos)
+ maps.Copy(existingConfig.Contexts, newConfig.Contexts)
+ maps.Copy(existingConfig.Clusters, newConfig.Clusters)
+
+ err = clientcmd.WriteToFile(*existingConfig, pathDestionationKubeConfig)
+ if err != nil {
+ return fmt.Errorf("error writing merged kubeconfig: %w", err)
+ }
+
+ return nil
+}
+
// WriteConfigFile writes the given data to the given path.
// The directory is created if it does not exist.
func WriteConfigFile(configPath, data string) error {
@@ -256,8 +297,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 2158e175a..b150509ec 100644
--- a/internal/pkg/services/ske/utils/utils_test.go
+++ b/internal/pkg/services/ske/utils/utils_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+ "k8s.io/client-go/tools/clientcmd"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
@@ -19,97 +20,72 @@ var (
)
const (
- testClusterName = "test-cluster"
+ testClusterName = "test-cluster"
+ existingKubeConfig = `
+apiVersion: v1
+clusters:
+- cluster:
+ certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJSjFTZ1NWTjhnMmt3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRBeE1UQXhNakkxTlRSYUZ3MHpOVEF4TURneE1qTXdOVFJhTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUM4ZXIwam1aS05STlR6Z2dCV3Q1cXMvaW94NXkxY2xBMHBGRHYwOWNmMGtmVGRVQWE3bmpqU0F2WlYKVFpsQlFFaW40Um9PTm1TZzdVMzVWN3FMSW56UVNmZXFuYi9wK05pODhDbkZvMThleUVnb3pHQklTTFpHK0EybQpuNFFEV3k3bVV1UUxFRnpjNjFpazdBQ0F5akZwRDlVdkdSdkxxVGJTQWcwYitYbktqbUUyWVgzTnRLbnJWOUN0CktrTG83K2JSa0MyemNkVnlraExhODhaR1BORUhjdVp2Uk0zQW5NclVGdGVvc0Fjb09xVW4xK09mYlhwUUlsTC8KKzBvRjcwN09Vc2tOUit0WEp4Z1VXL1R4Q0lONTYwU2E4eDVlWjB2VTZNR3ZOSTYwZ3h2S1lGL0pKa0pxU0NwNQovWWhpVmZ2QnNOSG5tVUZsNEdpOGFVMFNVTjRiQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJTUlkxVVhOamlMbFJLWktuSHJWRU55djA4aUp6QVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQVFaOVcwVFdvMQp4UFhPZU9xWHV6aFgzSkRoY0JVRkZyUVlOcHBMSmtqOWdUVm5Eck16b1dmeW9FQXRtT1ZQWURuTnEyTFhOSnpmClltd3RiUGxPemhGYkpWZVBWR0tLZktrUXZ1K3BhZGRtUHRhTzdUcnZqblRHeDhXczJadE5xK20wbkRGRUN4SDkKc1o2K1IycWhBUWNnSGdQWFZQdTdxSXFmbkNWRDkyeGprTE40c2JLZjRMb2x0R3hZbTBTWVZuY09rTFlBL3BvawpqTCsvODRJQXRrRXlEL21VdVF4MEsyVzFvVUM4dDRyMUlPZ3Y3OHZQMkRDRlBuZDVvbTJBM1dCNHY2dUFNZWc0Cnk3Y3FTcjBlSzJhNFQvMUtpTEdzYXI1V01ONTNwMjFiOGJMSTlISGNJMkh6c0tOdEdpNGFOT0hsWWkwUFgrUW0KT3U4NW4ycVdwSUxmCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
+ server: https://127.0.0.1:61274
+ name: existing-cluster
+contexts:
+- context:
+ cluster: existing-cluster
+ user: existing-cluster
+ name: existing-cluster
+current-context: existing-cluster
+kind: Config
+preferences: {}
+users:
+- name: existing-cluster
+ user:
+ client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURLVENDQWhHZ0F3SUJBZ0lJYWFEL3lTemlKM1F3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRBeE1UQXhNakkxTlRSYUZ3MHlOakF4TVRBeE1qTXdOVFJhTUR3eApIekFkQmdOVkJBb1RGbXQxWW1WaFpHMDZZMngxYzNSbGNpMWhaRzFwYm5NeEdUQVhCZ05WQkFNVEVHdDFZbVZ5CmJtVjBaWE10WVdSdGFXNHdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFEQSs2WjkKWU02RC9DK2VNWnJQRHZoR0VIRk4zeDVXdFMrVWlsb1F3QkJBSXdUNXFQczVnSERWK1cyWjdjT3VGNVFEYlpyUQo3dktWSUtlWXQ0Mk9SZytYQktibHhDV1VpdFZDdmZZbHJYKzlaY0JGL2dFaVBjOE9aK2h0Q1pPNlgyZ3d0WVNOCkgwZ1lLOTlhOFRWUWxlWm9Eem93WlE0Um5aSjhkRGo1STA2blRjdkk3bDBlMWt3VnM5aXFLRHpyekRhYnhqb0EKamZkcUpiZTVkOFc0ZTloTTRBdVRUbFRkWmFVTWFnUHhyaWxEOU9mUXhaUmlReFIzNkhSOHZabm9TcndXeWh5ZApqall0TFQvcE00UXAybUU5NFJqVWE2ekNUVlJKeWduY3RHVnpDRi84RDc1TVU4OVhmVjltQVV5L3BoR1M5MDdjCjlXbzE4Um42TytHNHYwdFRBZ01CQUFHalZqQlVNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUsKQmdnckJnRUZCUWNEQWpBTUJnTlZIUk1CQWY4RUFqQUFNQjhHQTFVZEl3UVlNQmFBRkpGalZSYzJPSXVWRXBrcQpjZXRVUTNLL1R5SW5NQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUMySVRwUlM1SlU3bGpkeDVRMlkwQzBkZG8yCm9PSmp5TUhVQXJ5ZTIyM2xOd1R1OTNHZXkvUjNIOHNpYWxDRURXdFR0cCsrY3BucW1ON05ia3UvWFI5SUlFdlIKYTNZS3VvbGdOTGtLaEtqMWQ0NVAxeEs0VE5CV1hSV2FMbksxcTdLVWxWWHp2bjdSN3RDY0NtNk90S3d4OUl2WgorRGhUU0pobFEzTVNmNXhjMUdOMm9qb0pPWmVlOXFNc3R1RzdPUVl1M08yUitYVUIwRHgzNnlPeFR2S0NBZ24xCm55Yk5FS0Nia1BmTXdvSU5aTm9iSWE3Y2VHcTdOMzRHaCs3Vi9iazUrQmhoTzVJRTRPeDYvUUxQc1B2ZGtOZHcKSkFyclQ3QytHSkF1UzNXQ2dYUXRyRWFyT3drWHhqajFPc3NuNjdMNlpONG01SkYzWHViSmdQUGZ3L2NECi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
+ client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBd1B1bWZXRE9nL3d2bmpHYXp3NzRSaEJ4VGQ4ZVZyVXZsSXBhRU1BUVFDTUUrYWo3Ck9ZQncxZmx0bWUzRHJoZVVBMjJhME83eWxTQ25tTGVOamtZUGx3U201Y1FsbElyVlFyMzJKYTEvdldYQVJmNEIKSWozUERtZm9iUW1UdWw5b01MV0VqUjlJR0N2Zld2RTFVSlhtYUE4Nk1HVU9FWjJTZkhRNCtTTk9wMDNMeU81ZApIdFpNRmJQWXFpZzg2OHcybThZNkFJMzNhaVczdVhmRnVIdllUT0FMazA1VTNXV2xER29EOGE0cFEvVG4wTVdVCllrTVVkK2gwZkwyWjZFcThGc29jblk0MkxTMC82VE9FS2RwaFBlRVkxR3Vzd2sxVVNjb0ozTFJsY3doZi9BKysKVEZQUFYzMWZaZ0ZNdjZZUmt2ZE8zUFZxTmZFWitqdmh1TDlMVXdJREFRQUJBb0lCQVFDS0lWWFk5anE3VS8zTgpjRm9MalA1K1AvU3B0V01rMHdsY2UrN2RnR3ZoVEcrYU42NmlTT0g2OWs3UjE5S3hRS1VzRXY2MlArSVloY2dRClVvbWE1V0R4U2w0ZnBkYjBUSzg2MTNkaEhwK0pORlI4aE1QUSs0YkNHL1BNWUFlQ1poblFpNHgxNm9jUzdnd3cKTHVoblp2UUZWYWpqek9GV0VJQXlYb29OSVkyQng3bjlzRlBGYmZSK1NOVVhuWHNHemFkMlArVmIyTkFCUjRFLwp4K2dYWlhFKzFnU0RhK25ZVHBiaG1hd3hreStEQnZBQlRWTzlWY2J2ZWoybDZ2WjAwK2lMTm9rYjF1UmJmbzNECkdEN2RZTjRYdCtwWXRMdFJYRGNqb2Q3OXpFcmJ4UkE4ZWoxblllOFpXQUNZa0ZOT3lpRHlJY3dFbWtDNXhlcHAKS1ByRGVCeEpBb0dCQU5XYzI4cFY4SDhRWm1Hb25QQkNZUUNrY2NLYnpEaXpwa0ZKMlZNVXZ5TG1Ia0w5bWlWUApQb1RsdXF4T2htMHhyRlNRaEFTQUlUaG0rWHN0c0pYdjNSd2dIZVdadTluUEVPeWpRcG02bTNEa1ZVK29kdTRGCnYwa25qdlduUTRPZnVQeDlCV3UyN1I3d1VBNHBqNUk0MGtlMVovdDZwdzZjeWFBckZ5L01HODZmQW9HQkFPZEcKMXRocFNUT3dZbEltWWoxNDdTSVJyb0VaSTNSaUNBVUh1ME54VEJObk5WL1JNVDdaNGVpZkRMMndXc0s1Q1Y0aQpFR2hBODRxYVB0dTFCaVhwTmdpMDBBdllWUGN6d0VDa3hocFdBeTJVRGZSc2FENnNYQ04ycVdtcGdjQzBTOWpICkdqUkdnSVFselhWNFVVcHFTYzVEUDZBYUFzRkhxVU1aT3dRZTgyck5Bb0dCQU5FN2FLbml6Y09ZQzhDQ2lONXAKRmx5cnRtWVpkc3JmWk95MGFqT1BzYng4VEkzdm04b0p1Y0l3eDAwNVNVQ3hsQXZzMWZNV2tmT09JYlkreGFYSApvZnVIbGVFc1dTejZQcWliTFlRb25WTFJ4S0pXNzg4clAvZG0wUWZiZ3l6dENTUC9UWXo1UzMrdmdhcXRtTnh2CjNjQ3hkcDJEd1JoMkNLUmpNTDMzbmhFZkFvR0FDNmNRRUJ0TjZ1TEtNV1Zwc2JzMEIzRm9uMnlLMHNSVnJ4c3kKbmpWSkpma2ZRVktpN28yL3loNnBYNjFSQlZxWlZEclhKTW1RKzd6RnlnQVc3VFlRMk9OelVBVjRVblF6RFk2Lwp4SGZzOVJEdW14QVRPSVVxcDBiRlJtT1ovQUdaaUxTUFoyN2Q3c3FRelloZ1lDVjJ6b09vNHdJc2ZWeUU5TEtDCnZMUnFnMGtDZ1lFQXlJRUdjeHQxcTIwdUhYUTFLTU92V2xWUUJCQklPUUJjeXoyR0djcWFGOHhSKzJCOGc3R2YKbEh4dHBvaTNNQUxTVXlhOTQzZEpMUHA4Q0xSOTBkQWtqZ1JROURPN2wyYWlWYWVncTA0NURCMnBwN05YVlc4NgptUXFPZUJRYzcyY0ZYdk9YZmRKUUQwME5HZThlS0VjTWN2QlhxTVIrSUtEdGozcGlKVjlsSHpBPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=`
+ newKubeConfig = `apiVersion: v1
+clusters:
+ - cluster:
+ certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJTjAvdmZkM3RCeGd3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRBeE1URXhOekV4TVRkYUZ3MHpOVEF4TURreE56RTJNVGRhTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUURJS25lRWZrM0F0WWlhanZyYWcwdU1zZUd1Q3BuZW80OXl3T0NFSmF0ZnVncVZXVXJ3cVd1WEdjUVgKTWp5MTZEVGxlR2YxeS83NXJuRUY1cld0Vm5wMDlNc0w1NW5YM0ZnT21SY3ozNmxtYTBOMmdMQU5RR0VmZU50NQpsa0Y5R2t6VFZMVy84alNWcXRkaTBCTm8xejEya0FCUm5yM1M0bWU0cExma0xFeWZKQTFQcnlpVUp0NnFBbldrCkUwV2RxbmJJMGRHQWZpZ3hTVFRZK09PMExWbjdJaG1QTGpPVEhHb0JRaW1DL091ZEZFK01FZG1kQkNOTHgzeE4KRDlSbk1taUxjVkVlSDlvVTFjYUdRamRIbXhnRUpJbStTOVdmWDZuRSsxOUpDZ0dkTS9KaFVtT0xRQWg4NzhMcQptc085WlNYdXFweW9ROTBhRDBDaFNNdzJyOXBQQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJRMXRjTE5rMmVjRkFJRDl5citZMnUyaHI4OWJEQVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQWdUZGJkTzZQNQo2M2hiZVRsS1E2UkpzRlkrdUdIeXcyMXNGU205Ni9vblZhOS91SjNQZ3BsMndKaFhpanZmZnNQamg2ekpkdTJXCll4WWkxcHdEWGZtMHpsNHJQMEcwQmkzL2Y1VkU0dkRnSmUwcDRKdkx2MWVmclZBcGhpakJiRkFHVTh6WVVPdEUKM2pGNy92ZDkvVUwxRWwzNVNRZjdEWWJhQ2NndzByS0tiNkQwaUZJcjJCRFZqbE01VDhqRzdETEk0a3pXTzFaTQpmNHh4ay9MQjBpY1R0a1RVRGQzcjBtZmFzNUdqR0lDR2QzbUpHbWY3bzFScXVyYlZ3dmVPWE5oL2tud2hnNGZqCitsTjJvaHpuaWdkTVNNQ1FnbDQ2NlowQTZvVDUrNUV6a2JwYS8yRDQ1cVN0ZGZBbTNtQ0RhdHdUelc1RlBudFMKMm0weVo2ZWVydkE4Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
+ server: https://127.0.0.1:55209
+ name: my-new-super-ske-cluster
+contexts:
+ - context:
+ cluster: my-new-super-ske-cluster
+ user: my-new-super-ske-cluster
+ name: my-new-super-ske-cluster
+current-context: my-new-super-ske-cluster
+kind: Config
+preferences: {}
+users:
+ - name: my-new-super-ske-cluster
+ user:
+ client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURLVENDQWhHZ0F3SUJBZ0lJUmpoS0w0dlJWSFV3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRBeE1URXhOekV4TVRkYUZ3MHlOakF4TVRFeE56RTJNVGRhTUR3eApIekFkQmdOVkJBb1RGbXQxWW1WaFpHMDZZMngxYzNSbGNpMWhaRzFwYm5NeEdUQVhCZ05WQkFNVEVHdDFZbVZ5CmJtVjBaWE10WVdSdGFXNHdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFEVE5WSmEKekJHZWU4OXVRNjVZWEdhT1pwTWJTZE9tcWFyNUlVbkRTUEpMbHdJKzkyWVRrcFBKcXFncWEwa2FZYVdZUmFlTQpCNVlDeTRpNjNXSTBYYlgvMW9LNUFPZ2xXL1FwcGczWnc5K3ZPYXdtdEpqUHQ1T2xEVWRONGdmYm40TjV1OWpoCmltQ09wak5VL285NzNZZy9nM3pqNi9nUm9EYldhaW5wSDltTk1nOHFTS0xaNkNpUlp2VjZuYkgyVDVSa3ZVVWgKUDNWN09CZE1oUlp3MW1rVVRQVXY5T056VVBubFFaS3hwWXphYjBiZm92eFd6UDhxQkVIdk9xaXZoWFhaaGp1bApaTU1OMjYrN2RyS3lCWS8rRnBmeGpqb3AyZytUSlMxNHhhOTh0dCtqT3dUUkI5aWh1WUQzTnlVbEZXVjhiUG51CnJqSW52ckxVcjkvQzB2cmhBZ01CQUFHalZqQlVNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUsKQmdnckJnRUZCUWNEQWpBTUJnTlZIUk1CQWY4RUFqQUFNQjhHQTFVZEl3UVlNQmFBRkRXMXdzMlRaNXdVQWdQMwpLdjVqYTdhR3Z6MXNNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUFZQkpld0ZwMTJnbkxQM1hGQ09JaXRZZWVnCkVmMjQwLysvaVFUUXQreHNjTU1ITGF4VjNFNEgxZ3JyNDdXUjE0bDdlbE1ING5qWnZzU3djSUZsa1RieVR6eW0KeW9XamhQQ0M2WWpzZHFEM2Vlc1ZpV2xhZkthczFrNmtmWHhVR2EvSUtQNzJoQ2tub2pia2o3amlSdjgrMTd5NgpKa2JIaXNYLzFqM2R1VHVIdDNORXJnNmNud0M5MGlldjZFZVFaV0oxaG5NSHhDMkRYMEdvOW14ZDlPYWFVODdBCkhBNDMzRnVJQWpoZjRWN2Vma3dGQU1ZMEhZSjZQaFZqTXdNWmdKczhLSHhVdjl3Y0xYMlFPUC9TSmhRZUtMV1UKYTFHTWlzTFBNc2NmL2JjU051SVpxMTR5S0xSelEwL1FIUW1PVVdSZDIva002MmxhbFl5Rlk2V0J4cCt3Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
+ client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBMHpWU1dzd1JubnZQYmtPdVdGeG1qbWFURzBuVHBxbXErU0ZKdzBqeVM1Y0NQdmRtCkU1S1R5YXFvS210SkdtR2xtRVduakFlV0FzdUl1dDFpTkYyMS85YUN1UURvSlZ2MEthWU4yY1Bmcnptc0pyU1kKejdlVHBRMUhUZUlIMjUrRGVidlk0WXBnanFZelZQNlBlOTJJUDROODQrdjRFYUEyMW1vcDZSL1pqVElQS2tpaQoyZWdva1diMWVwMng5aytVWkwxRklUOTFlemdYVElVV2NOWnBGRXoxTC9UamMxRDU1VUdTc2FXTTJtOUczNkw4ClZzei9LZ1JCN3pxb3I0VjEyWVk3cFdURERkdXZ1M2F5c2dXUC9oYVg4WTQ2S2RvUGt5VXRlTVd2ZkxiZm96c0UKMFFmWW9ibUE5emNsSlJWbGZHejU3cTR5Sjc2eTFLL2Z3dEw2NFFJREFRQUJBb0lCQVFDRDBQV1RJV1dscWRQdQpGMk9LVmpEVGt3VWd0TlRaWVc4SmlWTUdCRkxrQmwwcWV6RkQ2ZWsrcGJuS3I2YXlSbHNaUysram4yQnFZaWoxCnB4R1JhU01iaHYrVEF4UGZyU0lYbEVGMHRhQzNOYUZSanNrSWFxUkZFS0o5NHlIUVdoK3VMQ1RScnBGUXRqMjMKUUNEQXg2UXZMNXNVak1NSURSdnNlZG1xVzJ4bGg4UkF5RUdYVi9sUmJ5ZTdEOTIrWVpwd21kV3dsa2tiZy8yTQowdHF1R1k0Qk1XTFY0K09DVlNmVWVEWU1nZkZIL0RVWThUdUIvNitzVm9rUnhLalhYbjYzN1c4Q2dJWUVaQngrCkE5TG8vYk1YN0RaSDRmS0RyRCsycVQ1SDNUTDFIc3BtSXJ1Mi84RllCZ08ySjNzZVdHdHdtelVXalVzL2ExSGoKdXZMamNCTjVBb0dCQU83YitESTBsdFRGT29MSERISGNZdXZqMTYydU96bk51ejNXa1R0Sng3QzZJSVpVd2YwSQpuM2pJWXhKRi9yVVZUZzZPbU5XNXpGdDA2QTVWQitwZ2RNblFhOHMybVNldFlKVW51eE42emRsOGJoblZ6dXUzCi8walM3cU1pWGg5aU8vRlZ6VDNxcnNqU1VnMmNCRTA3WlZweU0ycVNMUlkrVmdiRDg4aUdUbXhiQW9HQkFPSmQKWWVNc1JpVVZ5Wk5sZU1ra3puS2pjYXoyOE9Vb0NyZjd0dVhaYUpqRDdWZncyWmNBd0cvZG5lZ3M2YmEvck54bgplMXU3Rm05VlNTR2pNejJEaC9QdlNuQlZReGtQeHo1ZFRja2V0RUJSQk1XaVV1enI2UUFXdmZudEZXcWNZTkpvClBCVWY3c2k4Wk1rMjJpanR1OWxEVnRRUFpJdDZUMzJrb0Z3eHNrcHpBb0dBYjQ0c2pNWWk2NXh4aDBLUGZWNEEKbFVzRUlBbVBmNSttSTJ0aXlOM2NkWjE0TTBUQ2xQckNBQmNXcmlJaW8xQWY5SXlFdE16aHRKVVZEQnlLWmR4RwpyenE0SFdDU2h3Vmlaa2I0Q0ZFQ2N1QzZTemFnUFZiaDA1RXdBdUM2Tk00Y1VNcFI0T2tLV0tCaDBobGJxUFprCmo2bG1lZzlySDBoZHhTc2ZZRGZaeUtFQ2dZQnZZMVk4ekZlRC9qR2YxMG5WYU1neC94MTc2RlBuMzRsT3VZMXAKazA3MkJVdHdmN01DckRzRmtQOFg5YW5YNUgveVFQV2gwUEVjUGRKcnUvd0Y1QWh0VDYzSWt4d2VZL1krU1BseQo0eW45a0NDU0ErdGNiRVhPWm1KN2JsK2dnMnpkZks4OEVlZVZYYWNXb0dnL3hhUXZLQVM4K3dvVjNFenJYYXdQClVlRVM0d0tCZ1FEUm9QbXkvNloySUdERkRReWt3YmFMRDlvQlZqN3BJSTI0NmlLM1hwQmRtRGFVR0hLYnRiNmUKYXNYRWNQQmp0enYvTzVOM2dlZWFYREduaW5XcXJJZm1FTzIyMDhmQ0VCc0RWc3RQMDhxRnorekFSMnJEQm9xbQpFVkwxN0o0Q2J6Tlh4bStOT1R6aVhCN2tLVWhNQUFBbmkwcXQ1QXN0QlJpcENuMER4Y2JpekE9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
+`
)
type skeClientMocked struct {
- getServiceStatusFails bool
- getServiceStatusResp *ske.ProjectResponse
listClustersFails bool
listClustersResp *ske.ListClustersResponse
listProviderOptionsFails bool
listProviderOptionsResp *ske.ProviderOptions
}
-func (m *skeClientMocked) GetServiceStatusExecute(_ context.Context, _ string) (*ske.ProjectResponse, error) {
- if m.getServiceStatusFails {
- return nil, fmt.Errorf("could not get service status")
- }
- return m.getServiceStatusResp, nil
-}
+const testRegion = "eu01"
-func (m *skeClientMocked) ListClustersExecute(_ context.Context, _ string) (*ske.ListClustersResponse, error) {
+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) {
+func (m *skeClientMocked) ListProviderOptionsExecute(_ context.Context, _ string) (*ske.ProviderOptions, error) {
if m.listProviderOptionsFails {
return nil, fmt.Errorf("could not list provider options")
}
return m.listProviderOptionsResp, nil
}
-func TestProjectEnabled(t *testing.T) {
- tests := []struct {
- description string
- getProjectFails bool
- getProjectResp *ske.ProjectResponse
- isValid bool
- expectedOutput bool
- }{
- {
- description: "project enabled",
- getProjectResp: &ske.ProjectResponse{State: ske.PROJECTSTATE_CREATED.Ptr()},
- isValid: true,
- expectedOutput: true,
- },
- {
- description: "project disabled 1",
- getProjectResp: &ske.ProjectResponse{State: ske.PROJECTSTATE_CREATING.Ptr()},
- isValid: true,
- expectedOutput: false,
- },
- {
- description: "project disabled 2",
- getProjectResp: &ske.ProjectResponse{State: ske.PROJECTSTATE_DELETING.Ptr()},
- isValid: true,
- expectedOutput: false,
- },
- {
- description: "get clusters fails",
- getProjectFails: true,
- isValid: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.description, func(t *testing.T) {
- client := &skeClientMocked{
- getServiceStatusFails: tt.getProjectFails,
- getServiceStatusResp: tt.getProjectResp,
- }
-
- output, err := ProjectEnabled(context.Background(), client, testProjectId)
-
- if tt.isValid && err != nil {
- t.Errorf("failed on valid input")
- }
- if !tt.isValid && err == nil {
- t.Errorf("did not fail on invalid input")
- }
- if !tt.isValid {
- return
- }
- if output != tt.expectedOutput {
- t.Errorf("expected output to be %t, got %t", tt.expectedOutput, output)
- }
- })
- }
-}
-
func TestClusterExists(t *testing.T) {
tests := []struct {
description string
@@ -150,7 +126,7 @@ func TestClusterExists(t *testing.T) {
listClustersResp: tt.getClustersResp,
}
- 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")
@@ -170,6 +146,17 @@ func TestClusterExists(t *testing.T) {
func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOptions {
providerOptions := &ske.ProviderOptions{
+ 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"),
@@ -193,10 +180,10 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt
Version: utils.Ptr("1.2.3"),
Cri: &[]ske.CRI{
{
- Name: utils.Ptr("not-containerd"),
+ Name: ske.CRINAME_DOCKER.Ptr(),
},
{
- Name: utils.Ptr("containerd"),
+ Name: ske.CRINAME_CONTAINERD.Ptr(),
},
},
},
@@ -205,10 +192,10 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt
Version: utils.Ptr("3.2.1"),
Cri: &[]ske.CRI{
{
- Name: utils.Ptr("not-containerd"),
+ Name: ske.CRINAME_DOCKER.Ptr(),
},
{
- Name: utils.Ptr("containerd"),
+ Name: ske.CRINAME_CONTAINERD.Ptr(),
},
},
},
@@ -222,7 +209,7 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt
Version: utils.Ptr("4.4.4"),
Cri: &[]ske.CRI{
{
- Name: utils.Ptr("containerd"),
+ Name: ske.CRINAME_CONTAINERD.Ptr(),
},
},
},
@@ -245,7 +232,7 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt
Version: utils.Ptr("4.4.4"),
Cri: &[]ske.CRI{
{
- Name: utils.Ptr("containerd"),
+ Name: ske.CRINAME_CONTAINERD.Ptr(),
},
},
},
@@ -259,7 +246,7 @@ func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOpt
Version: utils.Ptr("4.4.4"),
Cri: &[]ske.CRI{
{
- Name: utils.Ptr("not-containerd"),
+ Name: ske.CRINAME_DOCKER.Ptr(),
},
},
},
@@ -287,10 +274,12 @@ func fixtureGetDefaultPayload(mods ...func(*ske.CreateOrUpdateClusterPayload)) *
Nodepools: &[]ske.Nodepool{
{
AvailabilityZones: &[]string{
+ "eu01-1",
+ "eu01-2",
"eu01-3",
},
Cri: &ske.CRI{
- Name: utils.Ptr("containerd"),
+ Name: ske.CRINAME_CONTAINERD.Ptr(),
},
Machine: &ske.Machine{
Type: utils.Ptr("b1.2"),
@@ -299,10 +288,11 @@ func fixtureGetDefaultPayload(mods ...func(*ske.CreateOrUpdateClusterPayload)) *
Name: utils.Ptr("flatcar"),
},
},
- MaxSurge: utils.Ptr(int64(1)),
- Maximum: utils.Ptr(int64(2)),
- Minimum: utils.Ptr(int64(1)),
- Name: utils.Ptr("pool-default"),
+ MaxSurge: utils.Ptr(int64(3)),
+ MaxUnavailable: utils.Ptr(int64(0)),
+ Maximum: utils.Ptr(int64(3)),
+ Minimum: utils.Ptr(int64(1)),
+ Name: utils.Ptr("pool-default"),
Volume: &ske.Volume{
Type: utils.Ptr("storage_premium_perf2"),
Size: utils.Ptr(int64(50)),
@@ -335,6 +325,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) {
@@ -425,7 +443,7 @@ func TestGetDefaultPayload(t *testing.T) {
listProviderOptionsResp: tt.listProviderOptionsResp,
}
- output, err := GetDefaultPayload(context.Background(), client)
+ output, err := GetDefaultPayload(context.Background(), client, testRegion)
if tt.isValid && err != nil {
t.Errorf("failed on valid input")
@@ -534,33 +552,34 @@ func TestConvertToSeconds(t *testing.T) {
}
}
-func TestWriteConfigFile(t *testing.T) {
+func TestMergeKubeConfig(t *testing.T) {
tests := []struct {
- description string
- location string
- kubeconfig string
- isValid bool
- isLocationDir bool
- isLocationEmpty bool
- expectedErr string
+ description string
+ location string
+ kubeconfig string
+ existingKubeconfig string
+ isValid bool
+ isLocationDir bool
+ isLocationEmpty bool
+ expectedErr string
}{
{
description: "base",
location: filepath.Join("base", "config"),
- kubeconfig: "kubeconfig",
+ kubeconfig: newKubeConfig,
isValid: true,
},
{
description: "empty location",
location: "",
- kubeconfig: "kubeconfig",
+ kubeconfig: newKubeConfig,
isValid: false,
isLocationEmpty: true,
},
{
description: "path is only dir",
location: "only_dir",
- kubeconfig: "kubeconfig",
+ kubeconfig: newKubeConfig,
isValid: false,
isLocationDir: true,
},
@@ -570,6 +589,20 @@ func TestWriteConfigFile(t *testing.T) {
kubeconfig: "",
isValid: false,
},
+ {
+ description: "kubeconfig bad content",
+ location: filepath.Join("empty", "config"),
+ existingKubeconfig: "hola",
+ kubeconfig: "kubeconfig",
+ isValid: false,
+ },
+ {
+ description: "kubeconfig content",
+ location: filepath.Join("content", "config"),
+ kubeconfig: newKubeConfig,
+ existingKubeconfig: existingKubeConfig,
+ isValid: true,
+ },
}
baseTestDir := "test_data/"
@@ -579,27 +612,96 @@ func TestWriteConfigFile(t *testing.T) {
// make sure empty case still works
if tt.isLocationEmpty {
testLocation = ""
+ } else if tt.existingKubeconfig != "" {
+ dir := filepath.Dir(testLocation)
+
+ err := os.MkdirAll(dir, 0o700)
+ if err != nil {
+ t.Errorf("error create config directory: %s (%s)", dir, err.Error())
+ }
+
+ err = os.WriteFile(testLocation, []byte(tt.existingKubeconfig), 0o600)
+ if err != nil {
+ t.Errorf("could not write file: %s", tt.location)
+ }
+ defer func() {
+ err := os.Remove(testLocation)
+ if err != nil {
+ t.Errorf("could not deleete file: %s", tt.location)
+ }
+ }()
}
// filepath Join cleans trailing separators
if tt.isLocationDir {
testLocation += string(filepath.Separator)
}
- err := WriteConfigFile(testLocation, tt.kubeconfig)
+
+ err := MergeKubeConfig(testLocation, tt.kubeconfig)
if tt.isValid && err != nil {
- t.Errorf("failed on valid input")
+ t.Errorf("failed on valid input %s", err)
}
+
if !tt.isValid && err == nil {
t.Errorf("did not fail on invalid input")
}
if tt.isValid {
- data, err := os.ReadFile(testLocation)
+ kubeConfigFinal, err := clientcmd.LoadFromFile(testLocation)
if err != nil {
- t.Errorf("could not read file: %s", tt.location)
+ t.Errorf("error loading final kubeconfig: %s", err)
+ }
+
+ kubeConfigNew, err := clientcmd.Load([]byte(tt.kubeconfig))
+ if err != nil {
+ t.Errorf("error loading new kubeconfig: %s", err)
+ }
+
+ // check new kubeconfig is still there
+ for name := range kubeConfigNew.AuthInfos {
+ _, exits := kubeConfigFinal.AuthInfos[name]
+ if !exits {
+ t.Errorf("the user %s does not exist in the final kubeconfig", name)
+ }
+ }
+ for name := range kubeConfigNew.Contexts {
+ _, exits := kubeConfigFinal.Contexts[name]
+ if !exits {
+ t.Errorf("the context %s does not exist in the final kubeconfig", name)
+ }
+ }
+ for name := range kubeConfigNew.Clusters {
+ _, exits := kubeConfigFinal.Clusters[name]
+ if !exits {
+ t.Errorf("the cluster %s does not exist in the final kubeconfig", name)
+ }
}
- if string(data) != tt.kubeconfig {
- t.Errorf("expected file content to be %s, got %s", tt.kubeconfig, string(data))
+
+ if tt.existingKubeconfig != "" {
+ kubeConfigExisting, err := clientcmd.Load([]byte(tt.existingKubeconfig))
+ if err != nil {
+ t.Errorf("error loading existing kubeconfig: %s", err)
+ }
+
+ // check exiting kubeconfig is still there
+ for name := range kubeConfigExisting.AuthInfos {
+ _, exits := kubeConfigFinal.AuthInfos[name]
+ if !exits {
+ t.Errorf("the user %s does not exist in the final kubeconfig", name)
+ }
+ }
+ for name := range kubeConfigExisting.Contexts {
+ _, exits := kubeConfigFinal.Contexts[name]
+ if !exits {
+ t.Errorf("the context %s does not exist in the final kubeconfig", name)
+ }
+ }
+ for name := range kubeConfigExisting.Clusters {
+ _, exits := kubeConfigFinal.Clusters[name]
+ if !exits {
+ t.Errorf("the cluster %s does not exist in the final kubeconfig", name)
+ }
+ }
}
}
})
@@ -637,3 +739,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 0936d16f6..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, sdkConfig.WithRegion("eu01"))
-
- 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/services/sqlserverflex/utils/utils.go b/internal/pkg/services/sqlserverflex/utils/utils.go
index 11a88fc77..768507175 100644
--- a/internal/pkg/services/sqlserverflex/utils/utils.go
+++ b/internal/pkg/services/sqlserverflex/utils/utils.go
@@ -14,10 +14,15 @@ const (
ServiceCmd = "beta sqlserverflex"
)
+// enforce implementation of interfaces
+var (
+ _ SQLServerFlexClient = &sqlserverflex.APIClient{}
+)
+
type SQLServerFlexClient interface {
- ListVersionsExecute(ctx context.Context, projectId string) (*sqlserverflex.ListVersionsResponse, error)
- GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*sqlserverflex.GetInstanceResponse, error)
- GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*sqlserverflex.GetUserResponse, error)
+ ListVersionsExecute(ctx context.Context, projectId string, region string) (*sqlserverflex.ListVersionsResponse, error)
+ GetInstanceExecute(ctx context.Context, projectId, instanceId string, region string) (*sqlserverflex.GetInstanceResponse, error)
+ GetUserExecute(ctx context.Context, projectId, instanceId, userId string, region string) (*sqlserverflex.GetUserResponse, error)
}
func ValidateFlavorId(flavorId string, flavors *[]sqlserverflex.InstanceFlavorEntry) error {
@@ -85,16 +90,16 @@ func LoadFlavorId(cpu, ram int64, flavors *[]sqlserverflex.InstanceFlavorEntry)
}
}
-func GetInstanceName(ctx context.Context, apiClient SQLServerFlexClient, projectId, instanceId string) (string, error) {
- resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId)
+func GetInstanceName(ctx context.Context, apiClient SQLServerFlexClient, projectId, instanceId, region string) (string, error) {
+ resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId, region)
if err != nil {
return "", fmt.Errorf("get SQLServer Flex instance: %w", err)
}
return *resp.Item.Name, nil
}
-func GetUserName(ctx context.Context, apiClient SQLServerFlexClient, projectId, instanceId, userId string) (string, error) {
- resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId)
+func GetUserName(ctx context.Context, apiClient SQLServerFlexClient, projectId, instanceId, userId, region string) (string, error) {
+ resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId, region)
if err != nil {
return "", fmt.Errorf("get SQLServer Flex user: %w", err)
}
diff --git a/internal/pkg/services/sqlserverflex/utils/utils_test.go b/internal/pkg/services/sqlserverflex/utils/utils_test.go
index 8776baf4a..00c73e376 100644
--- a/internal/pkg/services/sqlserverflex/utils/utils_test.go
+++ b/internal/pkg/services/sqlserverflex/utils/utils_test.go
@@ -16,11 +16,15 @@ var (
testProjectId = uuid.NewString()
testInstanceId = uuid.NewString()
testUserId = uuid.NewString()
+
+ // enforce implementation of interfaces
+ _ SQLServerFlexClient = &sqlServerFlexClientMocked{}
)
const (
testInstanceName = "instance"
testUserName = "user"
+ testRegion = "eu01"
)
type sqlServerFlexClientMocked struct {
@@ -34,28 +38,28 @@ type sqlServerFlexClientMocked struct {
listRestoreJobsResp *sqlserverflex.ListRestoreJobsResponse
}
-func (m *sqlServerFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*sqlserverflex.ListVersionsResponse, error) {
+func (m *sqlServerFlexClientMocked) ListVersionsExecute(_ context.Context, _, _ string) (*sqlserverflex.ListVersionsResponse, error) {
if m.listVersionsFails {
return nil, fmt.Errorf("could not list versions")
}
return m.listVersionsResp, nil
}
-func (m *sqlServerFlexClientMocked) ListRestoreJobsExecute(_ context.Context, _, _ string) (*sqlserverflex.ListRestoreJobsResponse, error) {
+func (m *sqlServerFlexClientMocked) ListRestoreJobsExecute(_ context.Context, _, _, _ string) (*sqlserverflex.ListRestoreJobsResponse, error) {
if m.listRestoreJobsFails {
return nil, fmt.Errorf("could not list versions")
}
return m.listRestoreJobsResp, nil
}
-func (m *sqlServerFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*sqlserverflex.GetInstanceResponse, error) {
+func (m *sqlServerFlexClientMocked) GetInstanceExecute(_ context.Context, _, _, _ string) (*sqlserverflex.GetInstanceResponse, error) {
if m.getInstanceFails {
return nil, fmt.Errorf("could not get instance")
}
return m.getInstanceResp, nil
}
-func (m *sqlServerFlexClientMocked) GetUserExecute(_ context.Context, _, _, _ string) (*sqlserverflex.GetUserResponse, error) {
+func (m *sqlServerFlexClientMocked) GetUserExecute(_ context.Context, _, _, _, _ string) (*sqlserverflex.GetUserResponse, error) {
if m.getUserFails {
return nil, fmt.Errorf("could not get user")
}
@@ -405,7 +409,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")
@@ -434,7 +438,7 @@ func TestGetUserName(t *testing.T) {
{
description: "base",
getUserResp: &sqlserverflex.GetUserResponse{
- Item: &sqlserverflex.InstanceResponseUser{
+ Item: &sqlserverflex.UserResponseUser{
Username: utils.Ptr(testUserName),
},
},
@@ -455,7 +459,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/tables/tables.go b/internal/pkg/tables/tables.go
index de8e0b397..19f1eacd2 100644
--- a/internal/pkg/tables/tables.go
+++ b/internal/pkg/tables/tables.go
@@ -78,3 +78,14 @@ func (t *Table) Render() string {
func (t *Table) Display(p *print.Printer) error {
return p.PagerDisplay(t.Render())
}
+
+// Displays multiple tables in the command's stdout
+func DisplayTables(p *print.Printer, tables []Table) error {
+ renderedTables := ""
+
+ for _, t := range tables {
+ renderedTables += t.Render()
+ }
+
+ return p.PagerDisplay(renderedTables)
+}
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..a0deafbc7
--- /dev/null
+++ b/internal/pkg/testutils/parse_input.go
@@ -0,0 +1,126 @@
+// 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..32008eea3
--- /dev/null
+++ b/internal/pkg/testutils/parse_input_test.go
@@ -0,0 +1,169 @@
+// 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..ceecf888a
--- /dev/null
+++ b/internal/pkg/testutils/testutils.go
@@ -0,0 +1,121 @@
+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
new file mode 100644
index 000000000..64817f4ee
--- /dev/null
+++ b/internal/pkg/utils/strings.go
@@ -0,0 +1,77 @@
+package utils
+
+import (
+ "fmt"
+ "slices"
+ "strings"
+ "unicode/utf8"
+)
+
+// JoinStringKeys concatenates the string keys of a map, each separatore by the
+// [sep] string.
+func JoinStringKeys(m map[string]any, sep string) string {
+ keys := make([]string, len(m))
+ i := 0
+ for k := range m {
+ keys[i] = k
+ i++
+ }
+ return strings.Join(keys, sep)
+}
+
+// JoinStringKeysPtr concatenates the string keys of a map pointer, each separatore by the
+// [sep] string.
+func JoinStringKeysPtr(m map[string]any, sep string) string {
+ if m == nil {
+ return ""
+ }
+ 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 {
+ if vals == nil || len(*vals) == 0 {
+ return ""
+ }
+ return strings.Join(*vals, sep)
+}
+
+// Truncate trims the passed string (if it is not nil). If the input string is
+// longer than the given length, it is truncated to _maxLen_ and a ellipsis (…)
+// is attached. Therefore the resulting string has at most length _maxLen-1_
+func Truncate(s *string, maxLen int) string {
+ if s == nil {
+ return ""
+ }
+
+ if utf8.RuneCountInString(*s) > maxLen {
+ var builder strings.Builder
+ for i, r := range *s {
+ if i >= maxLen {
+ break
+ }
+ builder.WriteRune(r)
+ }
+ builder.WriteRune('…')
+ return builder.String()
+ }
+ return *s
+}
diff --git a/internal/pkg/utils/strings_test.go b/internal/pkg/utils/strings_test.go
new file mode 100644
index 000000000..6f0279045
--- /dev/null
+++ b/internal/pkg/utils/strings_test.go
@@ -0,0 +1,68 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/stackitcloud/stackit-sdk-go/core/utils"
+)
+
+func TestTruncate(t *testing.T) {
+ type args struct {
+ s *string
+ maxLen int
+ }
+ tests := []struct {
+ name string
+ args args
+ want string
+ }{
+ {"nil string", args{nil, 3}, ""},
+ {"empty string", args{utils.Ptr(""), 10}, ""},
+ {"length below maxlength", args{utils.Ptr("foo"), 10}, "foo"},
+ {"exactly maxlength", args{utils.Ptr("foo"), 3}, "foo"},
+ {"above maxlength", args{utils.Ptr("foobarbaz"), 3}, "foo…"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := Truncate(tt.args.s, tt.args.maxLen); got != tt.want {
+ t.Errorf("Truncate() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+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 f14ea7214..862b92c8f 100644
--- a/internal/pkg/utils/utils.go
+++ b/internal/pkg/utils/utils.go
@@ -1,10 +1,19 @@
package utils
import (
+ "encoding/base64"
"fmt"
+ "net/url"
+ "strings"
+ "time"
"github.com/google/uuid"
+ "github.com/inhies/go-bytesize"
"github.com/spf13/cobra"
+ "github.com/spf13/viper"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/config"
+ sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config"
+ "github.com/stackitcloud/stackit-sdk-go/services/iaas"
)
// Ptr Returns the pointer to any type T
@@ -12,6 +21,24 @@ func Ptr[T any](v T) *T {
return &v
}
+// PtrString creates a string representation of a passed object pointer or returns
+// an empty string, if the passed object is _nil_.
+func PtrString[T any](t *T) string {
+ if t != nil {
+ return fmt.Sprintf("%v", *t)
+ }
+ return ""
+}
+
+// PtrValue returns the dereferenced value if the pointer is not nil. Otherwise
+// the types zero element is returned
+func PtrValue[T any](t *T) (r T) {
+ if t != nil {
+ return *t
+ }
+ return r
+}
+
// Int64Ptr returns a pointer to an int64
// Needed because the Ptr function only returns pointer to int
func Int64Ptr(i int64) *int64 {
@@ -48,3 +75,187 @@ func ConvertInt64PToFloat64P(i *int64) *float64 {
f := float64(*i)
return &f
}
+
+func ValidateURLDomain(value string) error {
+ urlStruct, err := url.Parse(value)
+ if err != nil {
+ return fmt.Errorf("parse url: %w", err)
+ }
+ urlHost := urlStruct.Hostname()
+ if urlHost == "" {
+ return fmt.Errorf("bad url")
+ }
+
+ allowedUrlDomain := viper.GetString(config.AllowedUrlDomainKey)
+
+ if !strings.HasSuffix(urlHost, allowedUrlDomain) {
+ return fmt.Errorf(`only urls belonging to domain %s are allowed`, allowedUrlDomain)
+ }
+ return nil
+}
+
+// ConvertTimePToDateTimeString converts a time.Time pointer to a string represented as "2006-01-02 15:04:05"
+// This function will return an empty string if the input is nil
+func ConvertTimePToDateTimeString(t *time.Time) string {
+ if t == nil {
+ return ""
+ }
+ return t.Format(time.DateTime)
+}
+
+// PtrStringDefault return the value of a pointer [v] as string. If the pointer is nil, it returns the [defaultValue].
+func PtrStringDefault[T any](v *T, defaultValue string) string {
+ if v == nil {
+ return defaultValue
+ }
+ return fmt.Sprintf("%v", *v)
+}
+
+// PtrByteSizeDefault return the value of an in64 pointer to a string representation of bytesize. If the pointer is nil,
+// it returns the [defaultValue].
+func PtrByteSizeDefault(size *int64, defaultValue string) string {
+ if size == nil {
+ return defaultValue
+ }
+ 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 3dc7b54a7..4591c84c9 100644
--- a/internal/pkg/utils/utils_test.go
+++ b/internal/pkg/utils/utils_test.go
@@ -1,6 +1,16 @@
package utils
-import "testing"
+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"
+)
func TestConvertInt64PToFloat64P(t *testing.T) {
tests := []struct {
@@ -43,3 +53,540 @@ func TestConvertInt64PToFloat64P(t *testing.T) {
})
}
}
+
+func TestValidateURLDomain(t *testing.T) {
+ tests := []struct {
+ name string
+ allowedUrlDomain string
+ isValid bool
+ input string
+ }{
+ {
+ name: "STACKIT URL valid",
+ allowedUrlDomain: "stackit.cloud",
+ input: "https://example.stackit.cloud",
+ isValid: true,
+ },
+ {
+ name: "STACKIT URL invalid",
+ allowedUrlDomain: "example.com",
+ input: "https://example.stackit.cloud",
+ isValid: false,
+ },
+ {
+ name: "non-STACKIT URL invalid",
+ allowedUrlDomain: "stackit.cloud",
+ input: "https://www.very-suspicious-website.com/",
+ isValid: false,
+ },
+ {
+ name: "non-STACKIT URL valid",
+ allowedUrlDomain: "example.com",
+ input: "https://www.test.example.com/",
+ isValid: true,
+ },
+ {
+ name: "every URL valid",
+ allowedUrlDomain: "",
+ input: "https://www.test.example.com/",
+ isValid: true,
+ },
+ {
+ name: "invalid URL",
+ input: "",
+ isValid: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ viper.Reset()
+ viper.Set(config.AllowedUrlDomainKey, tt.allowedUrlDomain)
+
+ err := ValidateURLDomain(tt.input)
+ if tt.isValid && err != nil {
+ t.Errorf("expected URL to be valid, got error: %v", err)
+ }
+ if !tt.isValid && err == nil {
+ t.Errorf("expected URL to be invalid, got no error")
+ }
+ })
+ }
+}
+
+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
new file mode 100755
index 000000000..cb2058804
--- /dev/null
+++ b/scripts/check-docs.sh
@@ -0,0 +1,21 @@
+#!/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
+set -eo pipefail
+
+ROOT_DIR=$(git rev-parse --show-toplevel)
+
+before_hash=$(find docs -type f -exec sha256sum {} \; | sort | sha256sum | awk '{print $1}')
+
+# re-generate the docs
+go run $ROOT_DIR/scripts/generate.go
+
+after_hash=$(find docs -type f -exec sha256sum {} \; | sort | sha256sum | awk '{print $1}')
+
+if [[ "$before_hash" == "$after_hash" ]]; then
+ echo "Docs are up-to-date"
+else
+ echo "Changes detected. Docs are *not* up-to-date."
+ exit 1
+fi
diff --git a/scripts/generate.go b/scripts/generate.go
index 878078862..99d930910 100644
--- a/scripts/generate.go
+++ b/scripts/generate.go
@@ -27,12 +27,12 @@ func main() {
if err != nil {
log.Fatalf("Error removing old documentation directory: %v", err)
}
- err = os.Mkdir(docsDir, os.ModePerm)
+ err = os.Mkdir(docsDir, 0o750)
if err != nil {
log.Fatalf("Error creating new documentation directory: %v", err)
}
- filePrepender := func(filename string) string {
+ filePrepender := func(_ string) string {
return ""
}
linkHandler := func(filename string) string {
diff --git a/scripts/project.sh b/scripts/project.sh
deleted file mode 100755
index fd15c32e6..000000000
--- a/scripts/project.sh
+++ /dev/null
@@ -1,20 +0,0 @@
-#!/bin/bash
-
-# This script is used to manage the project, only used for installing the required tools for now
-# Usage: ./project.sh [action]
-# * tools: Install required tools to run the project
-set -eo pipefail
-
-ROOT_DIR=$(git rev-parse --show-toplevel)
-
-action=$1
-
-if [ "$action" = "help" ]; then
- [ -f "$0".man ] && man "$0".man || echo "No help, please read the script in ${script}, we will add help later"
-elif [ "$action" = "tools" ]; then
- cd ${ROOT_DIR}
- go mod download
- go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2
-else
- echo "Invalid action: '$action', please use $0 help for help"
-fi
diff --git a/scripts/publish-apt-packages.sh b/scripts/publish-apt-packages.sh
index 6f71243de..81aa53cb4 100755
--- a/scripts/publish-apt-packages.sh
+++ b/scripts/publish-apt-packages.sh
@@ -1,16 +1,13 @@
-#!/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)
-
-OBJECT_STORAGE_ENDPOINT="https://object.storage.eu01.onstackit.cloud"
+PACKAGES_BUCKET_URL="https://packages.stackit.cloud"
+PUBLIC_KEY_FILE_PATH="keys/key.gpg"
+APT_REPO_PATH="apt/cli"
APT_BUCKET_NAME="distribution"
-APT_REPO_FOLDER="apt/cli"
-PUBLIC_KEY_BUCKET_NAME="stackit-public-key"
-PUBLIC_KEY_FILE="key.gpg"
CUSTOM_KEYRING_FILE="aptly-keyring.gpg"
DISTRIBUTION="stackit"
APTLY_CONFIG_FILE_PATH="./.aptly.conf"
@@ -22,13 +19,13 @@ echo -n >~/.gnupg/common.conf
# Create a local mirror of the current state of the remote APT repository
printf ">>> Creating mirror \n"
-curl ${OBJECT_STORAGE_ENDPOINT}/${PUBLIC_KEY_BUCKET_NAME}/${PUBLIC_KEY_FILE} >public.asc
+curl ${PACKAGES_BUCKET_URL}/${PUBLIC_KEY_FILE_PATH} >public.asc
gpg --no-default-keyring --keyring=${CUSTOM_KEYRING_FILE} --import public.asc
-aptly mirror create -config "${APTLY_CONFIG_FILE_PATH}" -keyring="${CUSTOM_KEYRING_FILE}" current "${OBJECT_STORAGE_ENDPOINT}/${APT_BUCKET_NAME}/${APT_REPO_FOLDER}" ${DISTRIBUTION}
+aptly mirror create -config "${APTLY_CONFIG_FILE_PATH}" -keyring="${CUSTOM_KEYRING_FILE}" current "${PACKAGES_BUCKET_URL}/${APT_REPO_PATH}" ${DISTRIBUTION}
# 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"
@@ -52,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_FOLDER}"
+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
new file mode 100755
index 000000000..9326b1f72
--- /dev/null
+++ b/scripts/replace.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Add replace directives to local files to go.work
+set -eo pipefail
+
+while getopts "s:" option; do
+ case "${option}" in
+ s)
+ SDK_DIR=${OPTARG}
+ ;;
+
+ *)
+ echo "call: $0 [-s sdk-dir] "
+ exit 0
+ ;;
+ esac
+done
+shift $((OPTIND-1))
+
+if [ -z "$SDK_DIR" ]; then
+ SDK_DIR=../stackit-sdk-generator/sdk-repo-updated
+ echo "No SDK_DIR set, using $SDK_DIR"
+fi
+
+
+if [ ! -f go.work ]; then
+ go work init
+ go work use .
+else
+ echo "go.work already exists"
+fi
+
+if [ $# -gt 0 ];then
+ # modules passed via commandline
+ for service in $*; do
+ if [ ! -d $SDK_DIR/services/$service ]; then
+ echo "service directory $SDK_DIR/services/$service does not exist"
+ exit 1
+ fi
+ echo "replacing selected service $service"
+ if [ "$service" = "core" ]; then
+ go work edit -replace github.com/stackitcloud/stackit-sdk-go/core=$SDK_DIR/core
+ else
+ go work edit -replace github.com/stackitcloud/stackit-sdk-go/services/$service=$SDK_DIR/services/$service
+ fi
+ done
+else
+ # replace all modules
+ echo "replacing all services"
+ go work edit -replace github.com/stackitcloud/stackit-sdk-go/core=$SDK_DIR/core
+ for n in $(find ${SDK_DIR}/services -name go.mod);do
+ service=$(dirname $n)
+ service=${service#${SDK_DIR}/services/}
+ go work edit -replace github.com/stackitcloud/stackit-sdk-go/services/$service=$(dirname $n)
+ done
+fi
+go work edit -fmt
+go work sync