diff --git a/README.md b/README.md
index 888f2e03d..638180559 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ Below you can find a list of the STACKIT services already available in the CLI (
| Service | CLI Commands | Status |
| ---------------------------------- |----------------------------------------------------------------------------------------------------------------------| ------------------------- |
| Observability | `observability` | :white_check_mark: |
-| Infrastructure as a Service (IaaS) | `beta network-area`
`beta network`
`beta volume`
`beta network-interface`
`beta public-ip` | :white_check_mark: (beta) |
+| Infrastructure as a Service (IaaS) | `beta network-area`
`beta network`
`beta volume`
`beta network-interface`
`beta public-ip`
`beta key-pair` | :white_check_mark: (beta) |
| Authorization | `project`, `organization` | :white_check_mark: |
| DNS | `dns` | :white_check_mark: |
| Kubernetes Engine (SKE) | `ske` | :white_check_mark: |
diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md
index abc498479..acc708dd4 100644
--- a/docs/stackit_beta.md
+++ b/docs/stackit_beta.md
@@ -40,6 +40,7 @@ stackit beta [flags]
### SEE ALSO
* [stackit](./stackit.md) - Manage STACKIT resources using the command line
+* [stackit beta key-pair](./stackit_beta_key-pair.md) - Provides functionality for SSH key pairs
* [stackit beta network](./stackit_beta_network.md) - Provides functionality for networks
* [stackit beta network-area](./stackit_beta_network-area.md) - Provides functionality for STACKIT Network Area (SNA)
* [stackit beta network-interface](./stackit_beta_network-interface.md) - Provides functionality for network interfaces
diff --git a/docs/stackit_beta_key-pair.md b/docs/stackit_beta_key-pair.md
new file mode 100644
index 000000000..dfbdaa355
--- /dev/null
+++ b/docs/stackit_beta_key-pair.md
@@ -0,0 +1,37 @@
+## stackit beta key-pair
+
+Provides functionality for SSH key pairs
+
+### Synopsis
+
+Provides functionality for SSH key pairs
+
+```
+stackit beta key-pair [flags]
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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
+ --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 key-pair create](./stackit_beta_key-pair_create.md) - Creates a key pair
+* [stackit beta key-pair delete](./stackit_beta_key-pair_delete.md) - Deletes a key pair
+* [stackit beta key-pair describe](./stackit_beta_key-pair_describe.md) - Describes a key pair
+* [stackit beta key-pair list](./stackit_beta_key-pair_list.md) - Lists all key pairs
+* [stackit beta key-pair update](./stackit_beta_key-pair_update.md) - Updates a key pair
+
diff --git a/docs/stackit_beta_key-pair_create.md b/docs/stackit_beta_key-pair_create.md
new file mode 100644
index 000000000..72ce7dfdf
--- /dev/null
+++ b/docs/stackit_beta_key-pair_create.md
@@ -0,0 +1,51 @@
+## stackit beta key-pair create
+
+Creates a key pair
+
+### Synopsis
+
+Creates a key pair.
+
+```
+stackit beta key-pair create [flags]
+```
+
+### Examples
+
+```
+ Create a new key pair with public-key "ssh-rsa xxx"
+ $ stackit beta 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 beta 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 beta 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 beta key-pair create --public-key `ssh-rsa xxx` --labels key=value,key1=value1
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta key-pair](./stackit_beta_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_beta_key-pair_delete.md b/docs/stackit_beta_key-pair_delete.md
new file mode 100644
index 000000000..15de8a582
--- /dev/null
+++ b/docs/stackit_beta_key-pair_delete.md
@@ -0,0 +1,39 @@
+## stackit beta key-pair delete
+
+Deletes a key pair
+
+### Synopsis
+
+Deletes a key pair.
+
+```
+stackit beta key-pair delete [flags]
+```
+
+### Examples
+
+```
+ Delete key pair with name "KEY_PAIR_NAME"
+ $ stackit beta key-pair delete KEY_PAIR_NAME
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta key-pair](./stackit_beta_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_beta_key-pair_describe.md b/docs/stackit_beta_key-pair_describe.md
new file mode 100644
index 000000000..54b0189bc
--- /dev/null
+++ b/docs/stackit_beta_key-pair_describe.md
@@ -0,0 +1,43 @@
+## stackit beta key-pair describe
+
+Describes a key pair
+
+### Synopsis
+
+Describes a key pair.
+
+```
+stackit beta key-pair describe [flags]
+```
+
+### Examples
+
+```
+ Get details about a key pair with name "KEY_PAIR_NAME"
+ $ stackit beta key-pair describe KEY_PAIR_NAME
+
+ Get only the SSH public key of a key pair with name "KEY_PAIR_NAME"
+ $ stackit beta key-pair describe KEY_PAIR_NAME --public-key
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta key-pair](./stackit_beta_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_beta_key-pair_list.md b/docs/stackit_beta_key-pair_list.md
new file mode 100644
index 000000000..8b04bdbe2
--- /dev/null
+++ b/docs/stackit_beta_key-pair_list.md
@@ -0,0 +1,50 @@
+## stackit beta key-pair list
+
+Lists all key pairs
+
+### Synopsis
+
+Lists all key pairs.
+
+```
+stackit beta key-pair list [flags]
+```
+
+### Examples
+
+```
+ Lists all key pairs
+ $ stackit beta key-pair list
+
+ Lists all key pairs which contains the label xxx
+ $ stackit beta key-pair list --label-selector xxx
+
+ Lists all key pairs in JSON format
+ $ stackit beta key-pair list --output-format json
+
+ Lists up to 10 key pairs
+ $ stackit beta key-pair list --limit 10
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta key-pair](./stackit_beta_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/docs/stackit_beta_key-pair_update.md b/docs/stackit_beta_key-pair_update.md
new file mode 100644
index 000000000..1dc94a8e8
--- /dev/null
+++ b/docs/stackit_beta_key-pair_update.md
@@ -0,0 +1,40 @@
+## stackit beta key-pair update
+
+Updates a key pair
+
+### Synopsis
+
+Updates a key pair.
+
+```
+stackit beta key-pair update [flags]
+```
+
+### Examples
+
+```
+ Update the labels of a key pair with name "KEY_PAIR_NAME" with "key=value,key1=value1"
+ $ stackit beta key-pair update KEY_PAIR_NAME --labels key=value,key1=value1
+```
+
+### Options
+
+```
+ -h, --help Help for "stackit beta 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
+ --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info")
+```
+
+### SEE ALSO
+
+* [stackit beta key-pair](./stackit_beta_key-pair.md) - Provides functionality for SSH key pairs
+
diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go
index 389c0d614..975704383 100644
--- a/internal/cmd/beta/beta.go
+++ b/internal/cmd/beta/beta.go
@@ -3,6 +3,7 @@ package beta
import (
"fmt"
+ keypair "github.com/stackitcloud/stackit-cli/internal/cmd/beta/key-pair"
"github.com/stackitcloud/stackit-cli/internal/cmd/beta/network"
networkArea "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-area"
networkinterface "github.com/stackitcloud/stackit-cli/internal/cmd/beta/network-interface"
@@ -48,4 +49,5 @@ func addSubcommands(cmd *cobra.Command, p *print.Printer) {
cmd.AddCommand(volume.NewCmd(p))
cmd.AddCommand(networkinterface.NewCmd(p))
cmd.AddCommand(publicip.NewCmd(p))
+ cmd.AddCommand(keypair.NewCmd(p))
}
diff --git a/internal/cmd/beta/key-pair/create/create.go b/internal/cmd/beta/key-pair/create/create.go
new file mode 100644
index 000000000..4626946b2
--- /dev/null
+++ b/internal/cmd/beta/key-pair/create/create.go
@@ -0,0 +1,162 @@
+package create
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/stackitcloud/stackit-cli/internal/pkg/examples"
+ "github.com/stackitcloud/stackit-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/goccy/go-yaml"
+ "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(p *print.Printer) *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 beta 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 beta 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 beta 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 beta key-pair create --public-key `ssh-rsa xxx` --labels key=value,key1=value1",
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ ctx := context.Background()
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ return err
+ }
+
+ // Configure client
+ apiClient, err := client.ConfigureClient(p)
+ if err != nil {
+ return err
+ }
+
+ if !model.AssumeYes {
+ prompt := "Are your sure you want to create a key pair?"
+ err = p.PromptForConfirmation(prompt)
+ if err != nil {
+ return err
+ }
+ }
+
+ // Call API
+ req := buildRequest(ctx, model, apiClient)
+ resp, err := req.Execute()
+ if err != nil {
+ return fmt.Errorf("create key pair: %w", err)
+ }
+
+ return outputResult(p, model, 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) (*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),
+ }
+
+ if p.IsVerbosityDebug() {
+ modelStr, err := print.BuildDebugStrFromInputModel(model)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "convert model to string fo debugging: %v", err)
+ } else {
+ p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
+ }
+ }
+
+ return &model, nil
+}
+
+func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateKeyPairRequest {
+ req := apiClient.CreateKeyPair(ctx)
+
+ var labelsMap *map[string]interface{}
+ if model.Labels != nil && len(*model.Labels) > 0 {
+ // convert map[string]string to map[string]interface{}
+ labelsMap = utils.Ptr(map[string]interface{}{})
+ for k, v := range *model.Labels {
+ (*labelsMap)[k] = v
+ }
+ }
+
+ payload := iaas.CreateKeyPairPayload{
+ Name: model.Name,
+ Labels: labelsMap,
+ PublicKey: model.PublicKey,
+ }
+
+ return req.CreateKeyPairPayload(payload)
+}
+
+func outputResult(p *print.Printer, model *inputModel, item *iaas.Keypair) error {
+ switch model.OutputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(item, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal key pair: %w", err)
+ }
+ p.Outputln(string(details))
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(item, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal key pair: %w", err)
+ }
+ p.Outputln(string(details))
+ default:
+ p.Outputf("Created key pair %q.\nkey pair Fingerprint: %q\n", *item.Name, *item.Fingerprint)
+ }
+ return nil
+}
diff --git a/internal/cmd/beta/key-pair/create/create_test.go b/internal/cmd/beta/key-pair/create/create_test.go
new file mode 100644
index 000000000..adb0f90b7
--- /dev/null
+++ b/internal/cmd/beta/key-pair/create/create_test.go
@@ -0,0 +1,196 @@
+package create
+
+import (
+ "context"
+ "os"
+ "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/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
+ 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) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err = cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting 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)
+ }
+ })
+ }
+}
+
+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)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/key-pair/create/template/id_ed25519.pub b/internal/cmd/beta/key-pair/create/template/id_ed25519.pub
new file mode 100644
index 000000000..082c95349
--- /dev/null
+++ b/internal/cmd/beta/key-pair/create/template/id_ed25519.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG1ogKtJ5SElBm3mxhFhdvXxXiz+FxYoOvcdWSW2/ZI
diff --git a/internal/cmd/beta/key-pair/delete/delete.go b/internal/cmd/beta/key-pair/delete/delete.go
new file mode 100644
index 000000000..0cc0c77bc
--- /dev/null
+++ b/internal/cmd/beta/key-pair/delete/delete.go
@@ -0,0 +1,98 @@
+package delete
+
+import (
+ "context"
+ "fmt"
+
+ "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(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "delete",
+ 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 beta key-pair delete KEY_PAIR_NAME",
+ ),
+ ),
+ 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 delete key pair %q?", model.KeyPairName)
+ 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("delete key pair: %w", err)
+ }
+
+ p.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,
+ }
+
+ if p.IsVerbosityDebug() {
+ modelStr, err := print.BuildDebugStrFromInputModel(model)
+ if 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 *iaas.APIClient) iaas.ApiDeleteKeyPairRequest {
+ return apiClient.DeleteKeyPair(ctx, model.KeyPairName)
+}
diff --git a/internal/cmd/beta/key-pair/delete/delete_test.go b/internal/cmd/beta/key-pair/delete/delete_test.go
new file mode 100644
index 000000000..cfbcd29e8
--- /dev/null
+++ b/internal/cmd/beta/key-pair/delete/delete_test.go
@@ -0,0 +1,176 @@
+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-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(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err = cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.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/beta/key-pair/describe/describe.go b/internal/cmd/beta/key-pair/describe/describe.go
new file mode 100644
index 000000000..f45f11dff
--- /dev/null
+++ b/internal/cmd/beta/key-pair/describe/describe.go
@@ -0,0 +1,177 @@
+package describe
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "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(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "describe",
+ 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 beta key-pair describe KEY_PAIR_NAME",
+ ),
+ examples.NewExample(
+ `Get only the SSH public key of a key pair with name "KEY_PAIR_NAME"`,
+ "$ stackit beta key-pair describe KEY_PAIR_NAME --public-key",
+ ),
+ ),
+ 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 key pair: %w", err)
+ }
+
+ return outputResult(p, model.OutputFormat, model.PublicKey, resp)
+ },
+ }
+ 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),
+ }
+
+ if p.IsVerbosityDebug() {
+ modelStr, err := print.BuildDebugStrFromInputModel(model)
+ if 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 *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))
+ if showOnlyPublicKey {
+ onlyPublicKey := map[string]string{
+ "publicKey": *keyPair.PublicKey,
+ }
+ details, err = yaml.MarshalWithOptions(onlyPublicKey, yaml.IndentSequence(true))
+ }
+
+ 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", *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", *keyPair.Fingerprint)
+ table.AddSeparator()
+
+ truncatedPublicKey := (*keyPair.PublicKey)[:maxLengthPublicKey] + "..."
+ table.AddRow("PUBLIC KEY", truncatedPublicKey)
+ table.AddSeparator()
+
+ table.AddRow("CREATED AT", *keyPair.CreatedAt)
+ table.AddSeparator()
+
+ table.AddRow("UPDATED AT", *keyPair.UpdatedAt)
+ table.AddSeparator()
+
+ p.Outputln(table.Render())
+ }
+
+ return nil
+}
diff --git a/internal/cmd/beta/key-pair/describe/describe_test.go b/internal/cmd/beta/key-pair/describe/describe_test.go
new file mode 100644
index 000000000..69add5828
--- /dev/null
+++ b/internal/cmd/beta/key-pair/describe/describe_test.go
@@ -0,0 +1,188 @@
+package describe
+
+import (
+ "context"
+ "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/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) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err = cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateArgs(tt.argsValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating args: %v", err)
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd, tt.argsValues)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatalf("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("Data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+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)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/key-pair/key-pair.go b/internal/cmd/beta/key-pair/key-pair.go
new file mode 100644
index 000000000..d8e6f91aa
--- /dev/null
+++ b/internal/cmd/beta/key-pair/key-pair.go
@@ -0,0 +1,33 @@
+package keypair
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/key-pair/create"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/key-pair/delete"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/key-pair/describe"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/key-pair/list"
+ "github.com/stackitcloud/stackit-cli/internal/cmd/beta/key-pair/update"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/print"
+ "github.com/stackitcloud/stackit-cli/internal/pkg/utils"
+)
+
+func NewCmd(p *print.Printer) *cobra.Command {
+ 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, p)
+ 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))
+}
diff --git a/internal/cmd/beta/key-pair/list/list.go b/internal/cmd/beta/key-pair/list/list.go
new file mode 100644
index 000000000..469a332fe
--- /dev/null
+++ b/internal/cmd/beta/key-pair/list/list.go
@@ -0,0 +1,171 @@
+package list
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "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/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(p *print.Printer) *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 beta key-pair list",
+ ),
+ examples.NewExample(
+ `Lists all key pairs which contains the label xxx`,
+ "$ stackit beta key-pair list --label-selector xxx",
+ ),
+ examples.NewExample(
+ `Lists all key pairs in JSON format`,
+ "$ stackit beta key-pair list --output-format json",
+ ),
+ examples.NewExample(
+ `Lists up to 10 key pairs`,
+ "$ stackit beta key-pair list --limit 10",
+ ),
+ ),
+ RunE: func(cmd *cobra.Command, _ []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
+ }
+
+ // 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 {
+ p.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(p, 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) (*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),
+ }
+
+ if p.IsVerbosityDebug() {
+ modelStr, err := print.BuildDebugStrFromInputModel(model)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
+ } else {
+ p.Debug(print.InfoLevel, modelStr)
+ }
+ }
+
+ 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 {
+ switch outputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(keyPairs, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal key pairs: %w", err)
+ }
+ p.Outputln(string(details))
+
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(keyPairs, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal key pairs: %w", err)
+ }
+ p.Outputln(string(details))
+
+ default:
+ table := tables.NewTable()
+ table.SetHeader("KEY PAIR NAME", "LABELS", "FINGERPRINT", "CREATED AT", "UPDATED AT")
+
+ for idx := range keyPairs {
+ keyPair := keyPairs[idx]
+
+ var labels []string
+ for key, value := range *keyPair.Labels {
+ labels = append(labels, fmt.Sprintf("%s: %s", key, value))
+ }
+
+ table.AddRow(*keyPair.Name, strings.Join(labels, ", "), *keyPair.Fingerprint, *keyPair.CreatedAt, *keyPair.UpdatedAt)
+ }
+
+ p.Outputln(table.Render())
+ }
+ return nil
+}
diff --git a/internal/cmd/beta/key-pair/list/list_test.go b/internal/cmd/beta/key-pair/list/list_test.go
new file mode 100644
index 000000000..99bedd4ba
--- /dev/null
+++ b/internal/cmd/beta/key-pair/list/list_test.go
@@ -0,0 +1,188 @@
+package list
+
+import (
+ "context"
+ "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/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
+ 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) {
+ p := print.NewPrinter()
+ cmd := NewCmd(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err = cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting flag --%s=%s: %v", flag, value, err)
+ }
+ }
+
+ err = cmd.ValidateRequiredFlags()
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error validating flags: %v", err)
+ }
+
+ model, err := parseInput(p, cmd)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("error parsing input: %v", err)
+ }
+
+ if !tt.isValid {
+ t.Fatal("did not fail on invalid input")
+ }
+ diff := cmp.Diff(model, tt.expectedModel)
+ if diff != "" {
+ t.Fatalf("data does not match: %s", diff)
+ }
+ })
+ }
+}
+
+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)
+ }
+ })
+ }
+}
diff --git a/internal/cmd/beta/key-pair/update/update.go b/internal/cmd/beta/key-pair/update/update.go
new file mode 100644
index 000000000..483cd7798
--- /dev/null
+++ b/internal/cmd/beta/key-pair/update/update.go
@@ -0,0 +1,143 @@
+package update
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "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/goccy/go-yaml"
+ "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(p *print.Printer) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "update",
+ 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 beta key-pair update KEY_PAIR_NAME --labels key=value,key1=value1",
+ ),
+ ),
+ 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 update key pair %q?", *model.KeyPairName)
+ err = p.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)
+ }
+
+ return outputResult(p, 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)
+
+ var labelsMap *map[string]interface{}
+ if model.Labels != nil && len(*model.Labels) > 0 {
+ // convert map[string]string to map[string]interface{}
+ labelsMap = utils.Ptr(map[string]interface{}{})
+ for k, v := range *model.Labels {
+ (*labelsMap)[k] = v
+ }
+ }
+ payload := iaas.UpdateKeyPairPayload{
+ Labels: labelsMap,
+ }
+ return req.UpdateKeyPairPayload(payload)
+}
+
+func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) {
+ keyPairName := inputArgs[0]
+ globalFlags := globalflags.Parse(p, cmd)
+
+ model := inputModel{
+ GlobalFlagModel: globalFlags,
+ Labels: flags.FlagToStringToStringPointer(p, cmd, labelsFlag),
+ KeyPairName: utils.Ptr(keyPairName),
+ }
+
+ if p.IsVerbosityDebug() {
+ modelStr, err := print.BuildDebugStrFromInputModel(model)
+ if err != nil {
+ p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err)
+ } else {
+ p.Debug(print.DebugLevel, "parsed input values: %s", modelStr)
+ }
+ }
+
+ return &model, nil
+}
+
+func outputResult(p *print.Printer, model *inputModel, keyPair *iaas.Keypair) error {
+ switch model.OutputFormat {
+ case print.JSONOutputFormat:
+ details, err := json.MarshalIndent(keyPair, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal key pair: %w", err)
+ }
+ p.Outputln(string(details))
+ case print.YAMLOutputFormat:
+ details, err := yaml.MarshalWithOptions(keyPair, yaml.IndentSequence(true))
+ if err != nil {
+ return fmt.Errorf("marshal key pair: %w", err)
+ }
+ p.Outputln(string(details))
+ default:
+ p.Outputf("Updated labels of key pair %q\n", *model.KeyPairName)
+ }
+ return nil
+}
diff --git a/internal/cmd/beta/key-pair/update/update_test.go b/internal/cmd/beta/key-pair/update/update_test.go
new file mode 100644
index 000000000..49d6aeabb
--- /dev/null
+++ b/internal/cmd/beta/key-pair/update/update_test.go
@@ -0,0 +1,190 @@
+package update
+
+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/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(p)
+ err := globalflags.Configure(cmd.Flags())
+ if err != nil {
+ t.Fatalf("configure global flags: %v", err)
+ }
+
+ for flag, value := range tt.flagValues {
+ err = cmd.Flags().Set(flag, value)
+ if err != nil {
+ if !tt.isValid {
+ return
+ }
+ t.Fatalf("setting 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.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)
+ }
+ })
+ }
+}