diff --git a/internal/cmd/iam/iam.go b/internal/cmd/iam/iam.go index 4113e3e..52545d5 100644 --- a/internal/cmd/iam/iam.go +++ b/internal/cmd/iam/iam.go @@ -14,5 +14,6 @@ func NewCommand(s *state.State) *cobra.Command { Long: `Manage IAM resources for the Qdrant Cloud account.`, Args: cobra.NoArgs, } + cmd.AddCommand(newKeyCommand(s)) return cmd } diff --git a/internal/cmd/iam/key.go b/internal/cmd/iam/key.go new file mode 100644 index 0000000..6a97f95 --- /dev/null +++ b/internal/cmd/iam/key.go @@ -0,0 +1,25 @@ +package iam + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newKeyCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "key", + Short: "Manage cloud management keys", + Long: `Manage cloud management keys for the account. + +Management keys authenticate requests to the Qdrant Cloud API. Use them to authorize +the CLI, automation scripts, or any other tooling that calls the Qdrant Cloud API.`, + Args: cobra.NoArgs, + } + cmd.AddCommand( + newKeyListCommand(s), + newKeyCreateCommand(s), + newKeyDeleteCommand(s), + ) + return cmd +} diff --git a/internal/cmd/iam/key_create.go b/internal/cmd/iam/key_create.go new file mode 100644 index 0000000..51d1562 --- /dev/null +++ b/internal/cmd/iam/key_create.go @@ -0,0 +1,66 @@ +package iam + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newKeyCreateCommand(s *state.State) *cobra.Command { + return base.CreateCmd[*authv1.ManagementKey]{ + Long: `Create a new cloud management key for the account. + +Management keys grant access to the Qdrant Cloud API. The full key value is returned +only once at creation time — store it securely, as it cannot be retrieved again. If a +key is lost, delete it and create a new one.`, + Example: `# Create a new management key +qcloud iam key create + +# Create and capture the key value in a script +qcloud iam key create --json | jq -r '.key'`, + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "create", + Short: "Create a cloud management key", + Args: cobra.NoArgs, + } + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) (*authv1.ManagementKey, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + + resp, err := client.Auth().CreateManagementKey(ctx, &authv1.CreateManagementKeyRequest{ + ManagementKey: &authv1.ManagementKey{ + AccountId: accountID, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create management key: %w", err) + } + + return resp.GetManagementKey(), nil + }, + PrintResource: func(_ *cobra.Command, out io.Writer, key *authv1.ManagementKey) { + fmt.Fprintf(out, "Management key %s created.\n", key.GetId()) + if k := key.GetKey(); k != "" { + fmt.Fprintln(out, "") + fmt.Fprintln(out, "Save this key now — it will not be shown again:") + fmt.Fprintf(out, " %s\n", k) + } + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/key_create_test.go b/internal/cmd/iam/key_create_test.go new file mode 100644 index 0000000..0909dc0 --- /dev/null +++ b/internal/cmd/iam/key_create_test.go @@ -0,0 +1,66 @@ +package iam_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestKeyCreate_PrintsIDAndKey(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.AuthServer.CreateManagementKeyCalls.Returns(&authv1.CreateManagementKeyResponse{ + ManagementKey: &authv1.ManagementKey{ + Id: "new-key-id", + Key: "super-secret-value", + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "key", "create") + require.NoError(t, err) + assert.Contains(t, stdout, "new-key-id") + assert.Contains(t, stdout, "super-secret-value") + assert.Contains(t, stdout, "Save this key now") + + req, ok := env.AuthServer.CreateManagementKeyCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetManagementKey().GetAccountId()) +} + +func TestKeyCreate_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AuthServer.CreateManagementKeyCalls.Returns(nil, fmt.Errorf("internal server error")) + + _, _, err := testutil.Exec(t, env, "iam", "key", "create") + require.Error(t, err) +} + +func TestKeyCreate_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AuthServer.CreateManagementKeyCalls.Returns(&authv1.CreateManagementKeyResponse{ + ManagementKey: &authv1.ManagementKey{ + Id: "json-key-id", + Key: "secret", + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "key", "create", "--json") + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + Key string `json:"key"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "json-key-id", result.ID) + assert.Equal(t, "secret", result.Key) +} diff --git a/internal/cmd/iam/key_delete.go b/internal/cmd/iam/key_delete.go new file mode 100644 index 0000000..ac302cb --- /dev/null +++ b/internal/cmd/iam/key_delete.go @@ -0,0 +1,71 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newKeyDeleteCommand(s *state.State) *cobra.Command { + return base.Cmd{ + Long: `Delete a cloud management key from the account. + +Deleting a key immediately revokes its access to the Qdrant Cloud API. Any client +using the deleted key will receive authentication errors. This action cannot be undone. + +A confirmation prompt is shown unless --force is passed.`, + Example: `# Delete a management key (with confirmation prompt) +qcloud iam key delete a1b2c3d4-e5f6-7890-abcd-ef1234567890 + +# Delete without confirmation +qcloud iam key delete a1b2c3d4-e5f6-7890-abcd-ef1234567890 --force`, + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a cloud management key", + Args: util.ExactArgs(1, "a management key ID"), + } + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + return cmd + }, + ValidArgsFunction: completion.ManagementKeyIDCompletion(s), + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + keyID := args[0] + + force, _ := cmd.Flags().GetBool("force") + if !util.ConfirmAction(force, cmd.ErrOrStderr(), fmt.Sprintf("Are you sure you want to delete management key %s?", keyID)) { + fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") + return nil + } + + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + + accountID, err := s.AccountID() + if err != nil { + return err + } + + _, err = client.Auth().DeleteManagementKey(ctx, &authv1.DeleteManagementKeyRequest{ + AccountId: accountID, + ManagementKeyId: keyID, + }) + if err != nil { + return fmt.Errorf("failed to delete management key: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Management key %s deleted.\n", keyID) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/key_delete_test.go b/internal/cmd/iam/key_delete_test.go new file mode 100644 index 0000000..79e2436 --- /dev/null +++ b/internal/cmd/iam/key_delete_test.go @@ -0,0 +1,72 @@ +package iam_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestKeyDelete_WithForce(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.AuthServer.DeleteManagementKeyCalls.Returns(&authv1.DeleteManagementKeyResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "key", "delete", "key-abc", "--force") + require.NoError(t, err) + assert.Contains(t, stdout, "key-abc") + assert.Contains(t, stdout, "deleted") + + req, ok := env.AuthServer.DeleteManagementKeyCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, "key-abc", req.GetManagementKeyId()) +} + +func TestKeyDelete_Aborted(t *testing.T) { + env := testutil.NewTestEnv(t) + + stdout, _, err := testutil.Exec(t, env, "iam", "key", "delete", "key-abc") + require.NoError(t, err) + assert.Contains(t, stdout, "Aborted.") + assert.Equal(t, 0, env.AuthServer.DeleteManagementKeyCalls.Count()) +} + +func TestKeyDelete_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AuthServer.DeleteManagementKeyCalls.Returns(nil, fmt.Errorf("internal server error")) + + _, _, err := testutil.Exec(t, env, "iam", "key", "delete", "key-abc", "--force") + require.Error(t, err) +} + +func TestKeyDelete_MissingArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "key", "delete") + require.Error(t, err) +} + +func TestKeyDeleteCompletion(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AuthServer.ListManagementKeysCalls.Returns(&authv1.ListManagementKeysResponse{ + Items: []*authv1.ManagementKey{ + {Id: "key-uuid-1", Prefix: "abc123"}, + {Id: "key-uuid-2", Prefix: "def456"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "key", "delete", "") + require.NoError(t, err) + assert.Contains(t, stdout, "key-uuid-1") + assert.Contains(t, stdout, "abc123") + assert.Contains(t, stdout, "key-uuid-2") + assert.Contains(t, stdout, "def456") +} diff --git a/internal/cmd/iam/key_list.go b/internal/cmd/iam/key_list.go new file mode 100644 index 0000000..4969264 --- /dev/null +++ b/internal/cmd/iam/key_list.go @@ -0,0 +1,63 @@ +package iam + +import ( + "io" + + "github.com/spf13/cobra" + + authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newKeyListCommand(s *state.State) *cobra.Command { + return base.ListCmd[*authv1.ListManagementKeysResponse]{ + Use: "list", + Short: "List cloud management keys", + Long: `List all cloud management keys for the account. + +Management keys grant access to the Qdrant Cloud API and are used to authenticate CLI +and API requests. Each key is identified by its ID and a prefix — the prefix represents +the first bytes of the key value and is safe to display.`, + Example: `# List all management keys for the account +qcloud iam key list + +# Output as JSON +qcloud iam key list --json`, + Fetch: func(s *state.State, cmd *cobra.Command) (*authv1.ListManagementKeysResponse, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + + return client.Auth().ListManagementKeys(ctx, &authv1.ListManagementKeysRequest{ + AccountId: accountID, + }) + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *authv1.ListManagementKeysResponse) error { + t := output.NewTable[*authv1.ManagementKey](w) + t.AddField("ID", func(v *authv1.ManagementKey) string { + return v.GetId() + }) + t.AddField("PREFIX", func(v *authv1.ManagementKey) string { + return v.GetPrefix() + }) + t.AddField("CREATED", func(v *authv1.ManagementKey) string { + if v.GetCreatedAt() != nil { + return output.HumanTime(v.GetCreatedAt().AsTime()) + } + return "" + }) + t.Write(resp.GetItems()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/key_list_test.go b/internal/cmd/iam/key_list_test.go new file mode 100644 index 0000000..afc1263 --- /dev/null +++ b/internal/cmd/iam/key_list_test.go @@ -0,0 +1,87 @@ +package iam_test + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestKeyList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.AuthServer.ListManagementKeysCalls.Returns(&authv1.ListManagementKeysResponse{ + Items: []*authv1.ManagementKey{ + { + Id: "key-abc", + Prefix: "abc123", + CreatedAt: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "key", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "PREFIX") + assert.Contains(t, stdout, "CREATED") + assert.Contains(t, stdout, "key-abc") + assert.Contains(t, stdout, "abc123") + assert.Contains(t, stdout, "ago") + + req, ok := env.AuthServer.ListManagementKeysCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestKeyList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AuthServer.ListManagementKeysCalls.Returns(&authv1.ListManagementKeysResponse{ + Items: []*authv1.ManagementKey{ + {Id: "key-json", Prefix: "pref"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "key", "list", "--json") + require.NoError(t, err) + + var result struct { + Items []struct { + ID string `json:"id"` + Prefix string `json:"prefix"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.Len(t, result.Items, 1) + assert.Equal(t, "key-json", result.Items[0].ID) + assert.Equal(t, "pref", result.Items[0].Prefix) +} + +func TestKeyList_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AuthServer.ListManagementKeysCalls.Returns(nil, fmt.Errorf("internal server error")) + + _, _, err := testutil.Exec(t, env, "iam", "key", "list") + require.Error(t, err) +} + +func TestKeyList_Empty(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AuthServer.ListManagementKeysCalls.Returns(&authv1.ListManagementKeysResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "key", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "PREFIX") +}