diff --git a/docs/stackit_beta.md b/docs/stackit_beta.md index b58eb067a..23097f8ca 100644 --- a/docs/stackit_beta.md +++ b/docs/stackit_beta.md @@ -42,5 +42,6 @@ stackit beta [flags] * [stackit](./stackit.md) - Manage STACKIT resources using the command line * [stackit beta alb](./stackit_beta_alb.md) - Manages application loadbalancers +* [stackit beta kms](./stackit_beta_kms.md) - Provides functionality for KMS * [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex 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..631808f53 --- /dev/null +++ b/docs/stackit_beta_kms_key.md @@ -0,0 +1,39 @@ +## 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 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_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..6e65f3a47 --- /dev/null +++ b/docs/stackit_beta_kms_keyring.md @@ -0,0 +1,36 @@ +## 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 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_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..c10cb4946 --- /dev/null +++ b/docs/stackit_beta_kms_wrapping-key.md @@ -0,0 +1,36 @@ +## 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 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_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_sqlserverflex_instance_create.md b/docs/stackit_beta_sqlserverflex_instance_create.md index 6e8dc16a0..b297bf7b0 100644 --- a/docs/stackit_beta_sqlserverflex_instance_create.md +++ b/docs/stackit_beta_sqlserverflex_instance_create.md @@ -21,7 +21,7 @@ stackit beta sqlserverflex instance create [flags] $ stackit beta sqlserverflex instance create --name my-instance --flavor-id xxx Create a SQLServer Flex instance with name "my-instance", specify flavor by CPU and RAM, set storage size to 20 GB, and restrict access to a specific range of IP addresses. Other parameters are set to default values - $ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4 --storage-size 20 --acl 1.2.3.0/24 + $ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4 --storage-size 20 --acl 1.2.3.0/24 ``` ### Options diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md index b1abf5662..9b07e46b5 100644 --- a/docs/stackit_config_set.md +++ b/docs/stackit_config_set.md @@ -36,6 +36,7 @@ stackit config set [flags] --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 + --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 --mariadb-custom-endpoint string MariaDB API base URL, used in calls to this API diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md index 4a48b759e..9d4c83088 100644 --- a/docs/stackit_config_unset.md +++ b/docs/stackit_config_unset.md @@ -34,6 +34,7 @@ stackit config unset [flags] --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 + --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 --mariadb-custom-endpoint MariaDB API base URL. If unset, uses the default base URL diff --git a/go.mod b/go.mod index d152751e2..fe8258d95 100644 --- a/go.mod +++ b/go.mod @@ -238,6 +238,7 @@ require ( 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/stackitcloud/stackit-sdk-go/services/kms v1.0.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 diff --git a/go.sum b/go.sum index a896800c1..64b7cd08f 100644 --- a/go.sum +++ b/go.sum @@ -569,6 +569,8 @@ github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0 h1:7ZKd3b+E github.com/stackitcloud/stackit-sdk-go/services/authorization v0.9.0/go.mod h1:/FoXa6hF77Gv8brrvLBCKa5ie1Xy9xn39yfHwaln9Tw= github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1 h1:CnhAMLql0MNmAeq4roQKN8OpSKX4FSgTU6Eu6detB4I= github.com/stackitcloud/stackit-sdk-go/services/dns v0.17.1/go.mod h1:7Bx85knfNSBxulPdJUFuBePXNee3cO+sOTYnUG6M+iQ= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.0.0 h1:zxoOv7Fu+FmdsvTKiKkbmLItrMKfL+QoVtz9ReEF30E= +github.com/stackitcloud/stackit-sdk-go/services/kms v1.0.0/go.mod h1:KEPVoO21pC4bjy5l0nyhjUJ0+uVwVWb+k2TYrzJ8xYw= github.com/stackitcloud/stackit-sdk-go/services/git v0.8.0 h1:/weT7P5Uwy1Qlhw0NidqtQBlbbb/dQehweDV/I9ShXg= github.com/stackitcloud/stackit-sdk-go/services/git v0.8.0/go.mod h1:AXFfYBJZIW1o0W0zZEb/proQMhMsb3Nn5E1htS8NDPE= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.31.0 h1:dnEjyapuv8WwRN5vE2z6+4/+ZqQTBx+bX27x2nOF7Jw= diff --git a/internal/cmd/beta/beta.go b/internal/cmd/beta/beta.go index 5a007b87e..b026da770 100644 --- a/internal/cmd/beta/beta.go +++ b/internal/cmd/beta/beta.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/alb" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/kms" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex" "github.com/stackitcloud/stackit-cli/internal/cmd/params" "github.com/stackitcloud/stackit-cli/internal/pkg/args" @@ -38,4 +39,5 @@ func NewCmd(params *params.CmdParams) *cobra.Command { func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(sqlserverflex.NewCmd(params)) cmd.AddCommand(alb.NewCmd(params)) + cmd.AddCommand(kms.NewCmd(params)) } 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..1d815e638 --- /dev/null +++ b/internal/cmd/beta/kms/key/create/create.go @@ -0,0 +1,218 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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..f6c0a024e --- /dev/null +++ b/internal/cmd/beta/kms/key/create/create_test.go @@ -0,0 +1,327 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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(¶ms.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..56ab3059a --- /dev/null +++ b/internal/cmd/beta/kms/key/delete/delete.go @@ -0,0 +1,149 @@ +package delete + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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..b073b45be --- /dev/null +++ b/internal/cmd/beta/kms/key/delete/delete_test.go @@ -0,0 +1,292 @@ +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/cmd/params" + "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(¶ms.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(¶ms.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/importKey/importKey.go b/internal/cmd/beta/kms/key/importKey/importKey.go new file mode 100644 index 000000000..f491f6eb7 --- /dev/null +++ b/internal/cmd/beta/kms/key/importKey/importKey.go @@ -0,0 +1,181 @@ +package importKey + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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 *string = 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..37192e9d1 --- /dev/null +++ b/internal/cmd/beta/kms/key/importKey/importKey_test.go @@ -0,0 +1,362 @@ +package importKey + +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/cmd/params" + "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(¶ms.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(¶ms.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..4b2f7d8fa --- /dev/null +++ b/internal/cmd/beta/kms/key/key.go @@ -0,0 +1,36 @@ +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/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/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *params.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 *params.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)) +} 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..aa337b5b7 --- /dev/null +++ b/internal/cmd/beta/kms/key/list/list.go @@ -0,0 +1,148 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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..17d773bd5 --- /dev/null +++ b/internal/cmd/beta/kms/key/list/list_test.go @@ -0,0 +1,258 @@ +package list + +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/cmd/params" + "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(¶ms.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..05494a97a --- /dev/null +++ b/internal/cmd/beta/kms/key/restore/restore.go @@ -0,0 +1,148 @@ +package restore + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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..69860461d --- /dev/null +++ b/internal/cmd/beta/kms/key/restore/restore_test.go @@ -0,0 +1,292 @@ +package restore + +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/cmd/params" + "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(¶ms.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(¶ms.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..14e4ff15f --- /dev/null +++ b/internal/cmd/beta/kms/key/rotate/rotate.go @@ -0,0 +1,144 @@ +package rotate + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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..28d84295e --- /dev/null +++ b/internal/cmd/beta/kms/key/rotate/rotate_test.go @@ -0,0 +1,292 @@ +package rotate + +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/cmd/params" + "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(¶ms.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(¶ms.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..b4103e957 --- /dev/null +++ b/internal/cmd/beta/kms/keyring/create/create.go @@ -0,0 +1,182 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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..8cdf81219 --- /dev/null +++ b/internal/cmd/beta/kms/keyring/create/create_test.go @@ -0,0 +1,249 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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(¶ms.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..307729745 --- /dev/null +++ b/internal/cmd/beta/kms/keyring/delete/delete.go @@ -0,0 +1,105 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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/beta/kms/keyring/delete/delete_test.go b/internal/cmd/beta/kms/keyring/delete/delete_test.go new file mode 100644 index 000000000..c53e7d7f0 --- /dev/null +++ b/internal/cmd/beta/kms/keyring/delete/delete_test.go @@ -0,0 +1,212 @@ +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/cmd/params" + "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() +) + +// Args +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyRingId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +// Flags +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + globalflags.RegionFlag: testRegion, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +// Input Model +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Region: testRegion, + Verbosity: globalflags.VerbosityDefault, + }, + KeyRingId: testKeyRingId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +// Request +func fixtureRequest(mods ...func(request *kms.ApiDeleteKeyRingRequest)) kms.ApiDeleteKeyRingRequest { + request := testClient.DeleteKeyRing(testCtx, testProjectId, testRegion, testKeyRingId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no args (keyRingId)", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid keyRingId", + argValues: fixtureArgValues(func(argValues []string) { + argValues[0] = "Not an uuid" + }), + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(tt.expectedModel, model) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest kms.ApiDeleteKeyRingRequest + }{ + { + description: "base case", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(tt.expectedRequest, request, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/beta/kms/keyring/keyring.go b/internal/cmd/beta/kms/keyring/keyring.go new file mode 100644 index 000000000..7a42ce131 --- /dev/null +++ b/internal/cmd/beta/kms/keyring/keyring.go @@ -0,0 +1,30 @@ +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/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *params.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 *params.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(create.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..d12b9ae87 --- /dev/null +++ b/internal/cmd/beta/kms/keyring/list/list.go @@ -0,0 +1,133 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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..d85681c99 --- /dev/null +++ b/internal/cmd/beta/kms/keyring/list/list_test.go @@ -0,0 +1,229 @@ +package list + +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/cmd/params" + "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(¶ms.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..8eeb2b0d2 --- /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/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *params.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 *params.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..470099c66 --- /dev/null +++ b/internal/cmd/beta/kms/version/destroy/destroy.go @@ -0,0 +1,147 @@ +package destroy + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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..898d1a084 --- /dev/null +++ b/internal/cmd/beta/kms/version/destroy/destroy_test.go @@ -0,0 +1,319 @@ +package destroy + +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/cmd/params" + "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(¶ms.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(¶ms.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..095a870e4 --- /dev/null +++ b/internal/cmd/beta/kms/version/disable/disable.go @@ -0,0 +1,149 @@ +package disable + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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) + } + + // kms v1.0.0 has a waiter, but it get's stuck even though the disable api call was already successfully completed. + + // 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..66ec302b8 --- /dev/null +++ b/internal/cmd/beta/kms/version/disable/disable_test.go @@ -0,0 +1,320 @@ +package disable + +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/cmd/params" + "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(¶ms.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(¶ms.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..a2530996c --- /dev/null +++ b/internal/cmd/beta/kms/version/enable/enable.go @@ -0,0 +1,160 @@ +package enable + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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..381f7885a --- /dev/null +++ b/internal/cmd/beta/kms/version/enable/enable_test.go @@ -0,0 +1,320 @@ +package enable + +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/cmd/params" + "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(¶ms.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(¶ms.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..c73b2a949 --- /dev/null +++ b/internal/cmd/beta/kms/version/list/list.go @@ -0,0 +1,150 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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..a1df169ac --- /dev/null +++ b/internal/cmd/beta/kms/version/list/list_test.go @@ -0,0 +1,282 @@ +package list + +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/cmd/params" + "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(¶ms.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..c5b4e7aec --- /dev/null +++ b/internal/cmd/beta/kms/version/restore/restore.go @@ -0,0 +1,145 @@ +package restore + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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..ad388135e --- /dev/null +++ b/internal/cmd/beta/kms/version/restore/restore_test.go @@ -0,0 +1,320 @@ +package restore + +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/cmd/params" + "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(¶ms.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(¶ms.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..39c90e5c8 --- /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/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *params.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 *params.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..76d8be6b5 --- /dev/null +++ b/internal/cmd/beta/kms/wrappingkey/create/create.go @@ -0,0 +1,204 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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..15616c322 --- /dev/null +++ b/internal/cmd/beta/kms/wrappingkey/create/create_test.go @@ -0,0 +1,318 @@ +package create + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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(¶ms.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..0ade8822f --- /dev/null +++ b/internal/cmd/beta/kms/wrappingkey/delete/delete.go @@ -0,0 +1,118 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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 + } + + if !model.AssumeYes { + 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..57be1cbfa --- /dev/null +++ b/internal/cmd/beta/kms/wrappingkey/delete/delete_test.go @@ -0,0 +1,241 @@ +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/cmd/params" + "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(¶ms.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/list/list.go b/internal/cmd/beta/kms/wrappingkey/list/list.go new file mode 100644 index 000000000..a89babc53 --- /dev/null +++ b/internal/cmd/beta/kms/wrappingkey/list/list.go @@ -0,0 +1,149 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "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 *params.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..93eb4b88b --- /dev/null +++ b/internal/cmd/beta/kms/wrappingkey/list/list_test.go @@ -0,0 +1,250 @@ +package list + +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/cmd/params" + "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(¶ms.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..00184a521 --- /dev/null +++ b/internal/cmd/beta/kms/wrappingkey/wrappingkey.go @@ -0,0 +1,30 @@ +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/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *params.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 *params.CmdParams) { + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(create.NewCmd(params)) +} diff --git a/internal/cmd/beta/sqlserverflex/instance/create/create.go b/internal/cmd/beta/sqlserverflex/instance/create/create.go index cc5f2214d..8e349c6bc 100644 --- a/internal/cmd/beta/sqlserverflex/instance/create/create.go +++ b/internal/cmd/beta/sqlserverflex/instance/create/create.go @@ -80,7 +80,7 @@ func NewCmd(params *params.CmdParams) *cobra.Command { `$ stackit beta sqlserverflex instance create --name my-instance --flavor-id xxx`), examples.NewExample( `Create a SQLServer Flex instance with name "my-instance", specify flavor by CPU and RAM, set storage size to 20 GB, and restrict access to a specific range of IP addresses. Other parameters are set to default values`, - `$ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4 --storage-size 20 --acl 1.2.3.0/24`), + `$ stackit beta sqlserverflex instance create --name my-instance --cpu 1 --ram 4 --storage-size 20 --acl 1.2.3.0/24`), ), RunE: func(cmd *cobra.Command, args []string) error { ctx := context.Background() diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go index 5e4dfa540..e694e8901 100644 --- a/internal/cmd/config/set/set.go +++ b/internal/cmd/config/set/set.go @@ -37,6 +37,7 @@ const ( redisCustomEndpointFlag = "redis-custom-endpoint" resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint" secretsManagerCustomEndpointFlag = "secrets-manager-custom-endpoint" + kmsCustomEndpointFlag = "kms-custom-endpoint" serverBackupCustomEndpointFlag = "serverbackup-custom-endpoint" serverOsUpdateCustomEndpointFlag = "server-osupdate-custom-endpoint" runCommandCustomEndpointFlag = "runcommand-custom-endpoint" @@ -150,6 +151,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().String(redisCustomEndpointFlag, "", "Redis API base URL, used in calls to this API") cmd.Flags().String(resourceManagerCustomEndpointFlag, "", "Resource Manager API base URL, used in calls to this API") cmd.Flags().String(secretsManagerCustomEndpointFlag, "", "Secrets Manager API base URL, used in calls to this API") + cmd.Flags().String(kmsCustomEndpointFlag, "", "KMS API base URL, used in calls to this API") cmd.Flags().String(serviceAccountCustomEndpointFlag, "", "Service Account API base URL, used in calls to this API") cmd.Flags().String(serviceEnablementCustomEndpointFlag, "", "Service Enablement API base URL, used in calls to this API") cmd.Flags().String(serverBackupCustomEndpointFlag, "", "Server Backup API base URL, used in calls to this API") @@ -197,6 +199,8 @@ func configureFlags(cmd *cobra.Command) { cobra.CheckErr(err) err = viper.BindPFlag(config.SecretsManagerCustomEndpointKey, cmd.Flags().Lookup(secretsManagerCustomEndpointFlag)) cobra.CheckErr(err) + err = viper.BindPFlag(config.KMSCustomEndpointKey, cmd.Flags().Lookup(kmsCustomEndpointFlag)) + cobra.CheckErr(err) err = viper.BindPFlag(config.ServerBackupCustomEndpointKey, cmd.Flags().Lookup(serverBackupCustomEndpointFlag)) cobra.CheckErr(err) err = viper.BindPFlag(config.ServerOsUpdateCustomEndpointKey, cmd.Flags().Lookup(serverOsUpdateCustomEndpointFlag)) diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go index e7b6fd6fa..28ed935b9 100644 --- a/internal/cmd/config/unset/unset.go +++ b/internal/cmd/config/unset/unset.go @@ -41,6 +41,7 @@ const ( redisCustomEndpointFlag = "redis-custom-endpoint" resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint" secretsManagerCustomEndpointFlag = "secrets-manager-custom-endpoint" + kmsCustomEndpointFlag = "kms-custom-endpoint" serviceAccountCustomEndpointFlag = "service-account-custom-endpoint" serviceEnablementCustomEndpointFlag = "service-enablement-custom-endpoint" serverBackupCustomEndpointFlag = "serverbackup-custom-endpoint" @@ -78,6 +79,7 @@ type inputModel struct { RedisCustomEndpoint bool ResourceManagerCustomEndpoint bool SecretsManagerCustomEndpoint bool + KMSCustomEndpoint bool ServerBackupCustomEndpoint bool ServerOsUpdateCustomEndpoint bool RunCommandCustomEndpoint bool @@ -180,6 +182,9 @@ func NewCmd(params *params.CmdParams) *cobra.Command { if model.SecretsManagerCustomEndpoint { viper.Set(config.SecretsManagerCustomEndpointKey, "") } + if model.KMSCustomEndpoint { + viper.Set(config.KMSCustomEndpointKey, "") + } if model.ServiceAccountCustomEndpoint { viper.Set(config.ServiceAccountCustomEndpointKey, "") } @@ -245,6 +250,7 @@ func configureFlags(cmd *cobra.Command) { cmd.Flags().Bool(redisCustomEndpointFlag, false, "Redis API base URL. If unset, uses the default base URL") cmd.Flags().Bool(resourceManagerCustomEndpointFlag, false, "Resource Manager API base URL. If unset, uses the default base URL") cmd.Flags().Bool(secretsManagerCustomEndpointFlag, false, "Secrets Manager API base URL. If unset, uses the default base URL") + cmd.Flags().Bool(kmsCustomEndpointFlag, false, "KMS API base URL. If unset, uses the default base URL") cmd.Flags().Bool(serviceAccountCustomEndpointFlag, false, "Service Account API base URL. If unset, uses the default base URL") cmd.Flags().Bool(serviceEnablementCustomEndpointFlag, false, "Service Enablement API base URL. If unset, uses the default base URL") cmd.Flags().Bool(serverBackupCustomEndpointFlag, false, "Server Backup base URL. If unset, uses the default base URL") @@ -283,6 +289,7 @@ func parseInput(p *print.Printer, cmd *cobra.Command) *inputModel { RedisCustomEndpoint: flags.FlagToBoolValue(p, cmd, redisCustomEndpointFlag), ResourceManagerCustomEndpoint: flags.FlagToBoolValue(p, cmd, resourceManagerCustomEndpointFlag), SecretsManagerCustomEndpoint: flags.FlagToBoolValue(p, cmd, secretsManagerCustomEndpointFlag), + KMSCustomEndpoint: flags.FlagToBoolValue(p, cmd, kmsCustomEndpointFlag), ServiceAccountCustomEndpoint: flags.FlagToBoolValue(p, cmd, serviceAccountCustomEndpointFlag), ServiceEnablementCustomEndpoint: flags.FlagToBoolValue(p, cmd, serviceEnablementCustomEndpointFlag), ServerBackupCustomEndpoint: flags.FlagToBoolValue(p, cmd, serverBackupCustomEndpointFlag), diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go index 246696f15..e48dff384 100644 --- a/internal/cmd/config/unset/unset_test.go +++ b/internal/cmd/config/unset/unset_test.go @@ -35,6 +35,7 @@ func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool redisCustomEndpointFlag: true, resourceManagerCustomEndpointFlag: true, secretsManagerCustomEndpointFlag: true, + kmsCustomEndpointFlag: true, serviceAccountCustomEndpointFlag: true, serverBackupCustomEndpointFlag: true, serverOsUpdateCustomEndpointFlag: true, @@ -74,6 +75,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { RedisCustomEndpoint: true, ResourceManagerCustomEndpoint: true, SecretsManagerCustomEndpoint: true, + KMSCustomEndpoint: true, ServiceAccountCustomEndpoint: true, ServerBackupCustomEndpoint: true, ServerOsUpdateCustomEndpoint: true, @@ -129,6 +131,7 @@ func TestParseInput(t *testing.T) { model.RedisCustomEndpoint = false model.ResourceManagerCustomEndpoint = false model.SecretsManagerCustomEndpoint = false + model.KMSCustomEndpoint = false model.ServiceAccountCustomEndpoint = false model.ServerBackupCustomEndpoint = false model.ServerOsUpdateCustomEndpoint = false @@ -219,6 +222,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) { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 957d7c475..a8c136996 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -36,6 +36,7 @@ const ( RedisCustomEndpointKey = "redis_custom_endpoint" ResourceManagerEndpointKey = "resource_manager_custom_endpoint" SecretsManagerCustomEndpointKey = "secrets_manager_custom_endpoint" + KMSCustomEndpointKey = "kms_custom_endpoint" ServiceAccountCustomEndpointKey = "service_account_custom_endpoint" ServiceEnablementCustomEndpointKey = "service_enablement_custom_endpoint" ServerBackupCustomEndpointKey = "serverbackup_custom_endpoint" @@ -95,6 +96,7 @@ var ConfigKeys = []string{ RedisCustomEndpointKey, ResourceManagerEndpointKey, SecretsManagerCustomEndpointKey, + KMSCustomEndpointKey, ServiceAccountCustomEndpointKey, ServiceEnablementCustomEndpointKey, ServerBackupCustomEndpointKey, @@ -180,6 +182,7 @@ func setConfigDefaults() { viper.SetDefault(PostgresFlexCustomEndpointKey, "") viper.SetDefault(ResourceManagerEndpointKey, "") viper.SetDefault(SecretsManagerCustomEndpointKey, "") + viper.SetDefault(KMSCustomEndpointKey, "") viper.SetDefault(ServiceAccountCustomEndpointKey, "") viper.SetDefault(ServiceEnablementCustomEndpointKey, "") viper.SetDefault(ServerBackupCustomEndpointKey, "") diff --git a/internal/pkg/services/kms/client/client.go b/internal/pkg/services/kms/client/client.go new file mode 100644 index 000000000..b2cbd8250 --- /dev/null +++ b/internal/pkg/services/kms/client/client.go @@ -0,0 +1,45 @@ +package client + +import ( + "github.com/stackitcloud/stackit-cli/internal/pkg/auth" + "github.com/stackitcloud/stackit-cli/internal/pkg/config" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/kms" +) + +func ConfigureClient(p *print.Printer, cliVersion string) (*kms.APIClient, error) { + authCfgOption, err := auth.AuthenticationConfig(p, auth.AuthorizeUser) + if err != nil { + p.Debug(print.ErrorLevel, "configure authentication: %v", err) + return nil, &errors.AuthError{} + } + cfgOptions := []sdkConfig.ConfigurationOption{ + utils.UserAgentConfigOption(cliVersion), + authCfgOption, + } + + customEndpoint := viper.GetString(config.KMSCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + if p.IsVerbosityDebug() { + cfgOptions = append(cfgOptions, + sdkConfig.WithMiddleware(print.RequestResponseCapturer(p, nil)), + ) + } + + apiClient, err := kms.NewAPIClient(cfgOptions...) + if err != nil { + p.Debug(print.ErrorLevel, "create new API client: %v", err) + return nil, &errors.AuthError{} + } + + return apiClient, nil +} 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) + } + }) + } +}