Skip to content

Commit fd71d02

Browse files
authored
feat: add account and account member commands (#108)
1 parent 4071148 commit fd71d02

17 files changed

Lines changed: 1117 additions & 0 deletions

internal/cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/spf13/cobra"
77

8+
"github.com/qdrant/qcloud-cli/internal/cmd/account"
89
"github.com/qdrant/qcloud-cli/internal/cmd/backup"
910
"github.com/qdrant/qcloud-cli/internal/cmd/cloudprovider"
1011
"github.com/qdrant/qcloud-cli/internal/cmd/cloudregion"
@@ -65,6 +66,7 @@ Documentation: https://github.com/qdrant/qcloud-cli`,
6566
s.Config.BindPFlag(config.KeyAccountID, cmd.PersistentFlags().Lookup("account-id"))
6667
s.Config.BindPFlag(config.KeyEndpoint, cmd.PersistentFlags().Lookup("endpoint"))
6768

69+
cmd.AddCommand(account.NewCommand(s))
6870
cmd.AddCommand(version.NewCommand(s))
6971
cmd.AddCommand(iam.NewCommand(s))
7072
cmd.AddCommand(cluster.NewCommand(s))

internal/cmd/account/account.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package account
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/qdrant/qcloud-cli/internal/state"
7+
)
8+
9+
// NewCommand creates the account command group.
10+
func NewCommand(s *state.State) *cobra.Command {
11+
cmd := &cobra.Command{
12+
Use: "account",
13+
Short: "Manage Qdrant Cloud accounts",
14+
Long: `Manage Qdrant Cloud accounts and their members.
15+
16+
Use these commands to list, inspect, and update accounts that the current
17+
management key has access to. Account member commands show who belongs to the
18+
current account and whether they are the owner.`,
19+
Args: cobra.NoArgs,
20+
}
21+
cmd.AddCommand(
22+
newListCommand(s),
23+
newDescribeCommand(s),
24+
newUpdateCommand(s),
25+
newMemberCommand(s),
26+
)
27+
return cmd
28+
}

internal/cmd/account/describe.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package account
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"strings"
7+
8+
"github.com/spf13/cobra"
9+
10+
accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1"
11+
12+
"github.com/qdrant/qcloud-cli/internal/cmd/base"
13+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
14+
"github.com/qdrant/qcloud-cli/internal/cmd/output"
15+
"github.com/qdrant/qcloud-cli/internal/state"
16+
)
17+
18+
func newDescribeCommand(s *state.State) *cobra.Command {
19+
return base.DescribeCmd[*accountv1.Account]{
20+
Use: "describe [account-id]",
21+
Short: "Describe an account",
22+
Long: `Describe an account by its ID.
23+
24+
If no account ID is provided, the current account (from --account-id, the
25+
active context, or the QDRANT_CLOUD_ACCOUNT_ID environment variable) is used.`,
26+
Example: `# Describe the current account
27+
qcloud account describe
28+
29+
# Describe a specific account
30+
qcloud account describe a1b2c3d4-e5f6-7890-abcd-ef1234567890
31+
32+
# Output as JSON
33+
qcloud account describe --json`,
34+
Args: cobra.MaximumNArgs(1),
35+
Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*accountv1.Account, error) {
36+
ctx := cmd.Context()
37+
client, err := s.Client(ctx)
38+
if err != nil {
39+
return nil, err
40+
}
41+
42+
accountID, err := resolveAccountID(s, args)
43+
if err != nil {
44+
return nil, err
45+
}
46+
47+
resp, err := client.Account().GetAccount(ctx, &accountv1.GetAccountRequest{
48+
AccountId: accountID,
49+
})
50+
if err != nil {
51+
return nil, fmt.Errorf("failed to get account: %w", err)
52+
}
53+
54+
return resp.GetAccount(), nil
55+
},
56+
PrintText: func(_ *cobra.Command, w io.Writer, acct *accountv1.Account) error {
57+
fmt.Fprintf(w, "ID: %s\n", acct.GetId())
58+
fmt.Fprintf(w, "Name: %s\n", acct.GetName())
59+
fmt.Fprintf(w, "Owner Email: %s\n", acct.GetOwnerEmail())
60+
if company := acct.GetCompany(); company != nil {
61+
fmt.Fprintf(w, "Company: %s\n", company.GetName())
62+
if company.Domain != nil {
63+
fmt.Fprintf(w, "Domain: %s\n", company.GetDomain())
64+
}
65+
}
66+
if privs := acct.GetPrivileges(); len(privs) > 0 {
67+
fmt.Fprintf(w, "Privileges: %s\n", strings.Join(privs, ", "))
68+
}
69+
if acct.GetCreatedAt() != nil {
70+
t := acct.GetCreatedAt().AsTime()
71+
fmt.Fprintf(w, "Created: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t))
72+
}
73+
if acct.GetLastModifiedAt() != nil {
74+
t := acct.GetLastModifiedAt().AsTime()
75+
fmt.Fprintf(w, "Modified: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t))
76+
}
77+
return nil
78+
},
79+
ValidArgsFunction: completion.AccountIDCompletion(s),
80+
}.CobraCommand(s)
81+
}
82+
83+
// resolveAccountID returns args[0] if present, otherwise falls back to s.AccountID().
84+
func resolveAccountID(s *state.State, args []string) (string, error) {
85+
if len(args) > 0 {
86+
return args[0], nil
87+
}
88+
return s.AccountID()
89+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package account_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"google.golang.org/protobuf/types/known/timestamppb"
12+
13+
accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1"
14+
15+
"github.com/qdrant/qcloud-cli/internal/testutil"
16+
)
17+
18+
func TestAccountDescribe_TableOutput(t *testing.T) {
19+
env := testutil.NewTestEnv(t)
20+
21+
now := time.Now()
22+
env.AccountServer.GetAccountCalls.Returns(&accountv1.GetAccountResponse{
23+
Account: &accountv1.Account{
24+
Id: "acct-001",
25+
Name: "Production",
26+
OwnerEmail: "owner@example.com",
27+
Company: &accountv1.Company{
28+
Name: "Acme Corp",
29+
Domain: new("acme.com"),
30+
},
31+
Privileges: []string{"premium"},
32+
CreatedAt: timestamppb.New(now.Add(-48 * time.Hour)),
33+
},
34+
}, nil)
35+
36+
stdout, _, err := testutil.Exec(t, env, "account", "describe", "acct-001")
37+
require.NoError(t, err)
38+
assert.Contains(t, stdout, "acct-001")
39+
assert.Contains(t, stdout, "Production")
40+
assert.Contains(t, stdout, "owner@example.com")
41+
assert.Contains(t, stdout, "Acme Corp")
42+
assert.Contains(t, stdout, "acme.com")
43+
assert.Contains(t, stdout, "premium")
44+
45+
req, ok := env.AccountServer.GetAccountCalls.Last()
46+
require.True(t, ok)
47+
assert.Equal(t, "acct-001", req.GetAccountId())
48+
}
49+
50+
func TestAccountDescribe_DefaultsToCurrentAccount(t *testing.T) {
51+
env := testutil.NewTestEnv(t, testutil.WithAccountID("default-acct"))
52+
53+
env.AccountServer.GetAccountCalls.Returns(&accountv1.GetAccountResponse{
54+
Account: &accountv1.Account{
55+
Id: "default-acct",
56+
Name: "Default",
57+
},
58+
}, nil)
59+
60+
stdout, _, err := testutil.Exec(t, env, "account", "describe")
61+
require.NoError(t, err)
62+
assert.Contains(t, stdout, "default-acct")
63+
64+
req, ok := env.AccountServer.GetAccountCalls.Last()
65+
require.True(t, ok)
66+
assert.Equal(t, "default-acct", req.GetAccountId())
67+
}
68+
69+
func TestAccountDescribe_JSONOutput(t *testing.T) {
70+
env := testutil.NewTestEnv(t)
71+
72+
env.AccountServer.GetAccountCalls.Returns(&accountv1.GetAccountResponse{
73+
Account: &accountv1.Account{
74+
Id: "acct-json",
75+
Name: "JSON Account",
76+
OwnerEmail: "json@example.com",
77+
},
78+
}, nil)
79+
80+
stdout, _, err := testutil.Exec(t, env, "account", "describe", "acct-json", "--json")
81+
require.NoError(t, err)
82+
83+
var result struct {
84+
ID string `json:"id"`
85+
Name string `json:"name"`
86+
OwnerEmail string `json:"ownerEmail"`
87+
}
88+
require.NoError(t, json.Unmarshal([]byte(stdout), &result))
89+
assert.Equal(t, "acct-json", result.ID)
90+
assert.Equal(t, "JSON Account", result.Name)
91+
assert.Equal(t, "json@example.com", result.OwnerEmail)
92+
}
93+
94+
func TestAccountDescribe_BackendError(t *testing.T) {
95+
env := testutil.NewTestEnv(t)
96+
97+
env.AccountServer.GetAccountCalls.Returns(nil, fmt.Errorf("not found"))
98+
99+
_, _, err := testutil.Exec(t, env, "account", "describe", "acct-bad")
100+
require.Error(t, err)
101+
}
102+
103+
func TestAccountDescribe_TooManyArgs(t *testing.T) {
104+
env := testutil.NewTestEnv(t)
105+
106+
_, _, err := testutil.Exec(t, env, "account", "describe", "arg1", "arg2")
107+
require.Error(t, err)
108+
}

internal/cmd/account/list.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package account
2+
3+
import (
4+
"io"
5+
6+
"github.com/spf13/cobra"
7+
8+
accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1"
9+
10+
"github.com/qdrant/qcloud-cli/internal/cmd/base"
11+
"github.com/qdrant/qcloud-cli/internal/cmd/output"
12+
"github.com/qdrant/qcloud-cli/internal/state"
13+
)
14+
15+
func newListCommand(s *state.State) *cobra.Command {
16+
return base.ListCmd[*accountv1.ListAccountsResponse]{
17+
Use: "list",
18+
Short: "List accounts",
19+
Long: `List all accounts associated with the authenticated management key.
20+
21+
Returns every account the current API key has access to. No account ID is
22+
required because the server resolves accounts from the caller's credentials.`,
23+
Example: `# List all accessible accounts
24+
qcloud account list
25+
26+
# Output as JSON
27+
qcloud account list --json`,
28+
Fetch: func(s *state.State, cmd *cobra.Command) (*accountv1.ListAccountsResponse, error) {
29+
ctx := cmd.Context()
30+
client, err := s.Client(ctx)
31+
if err != nil {
32+
return nil, err
33+
}
34+
35+
return client.Account().ListAccounts(ctx, &accountv1.ListAccountsRequest{})
36+
},
37+
PrintText: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountsResponse) error {
38+
t := output.NewTable[*accountv1.Account](w)
39+
t.AddField("ID", func(v *accountv1.Account) string { return v.GetId() })
40+
t.AddField("NAME", func(v *accountv1.Account) string { return v.GetName() })
41+
t.AddField("OWNER EMAIL", func(v *accountv1.Account) string { return v.GetOwnerEmail() })
42+
t.AddField("CREATED", func(v *accountv1.Account) string {
43+
if v.GetCreatedAt() != nil {
44+
return output.HumanTime(v.GetCreatedAt().AsTime())
45+
}
46+
return ""
47+
})
48+
t.Write(resp.GetItems())
49+
return nil
50+
},
51+
}.CobraCommand(s)
52+
}

internal/cmd/account/list_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package account_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"google.golang.org/protobuf/types/known/timestamppb"
12+
13+
accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1"
14+
15+
"github.com/qdrant/qcloud-cli/internal/testutil"
16+
)
17+
18+
func TestAccountList_TableOutput(t *testing.T) {
19+
env := testutil.NewTestEnv(t)
20+
21+
env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{
22+
Items: []*accountv1.Account{
23+
{
24+
Id: "acct-001",
25+
Name: "Production",
26+
OwnerEmail: "owner@example.com",
27+
CreatedAt: timestamppb.New(time.Now().Add(-24 * time.Hour)),
28+
},
29+
},
30+
}, nil)
31+
32+
stdout, _, err := testutil.Exec(t, env, "account", "list")
33+
require.NoError(t, err)
34+
assert.Contains(t, stdout, "ID")
35+
assert.Contains(t, stdout, "NAME")
36+
assert.Contains(t, stdout, "OWNER EMAIL")
37+
assert.Contains(t, stdout, "CREATED")
38+
assert.Contains(t, stdout, "acct-001")
39+
assert.Contains(t, stdout, "Production")
40+
assert.Contains(t, stdout, "owner@example.com")
41+
assert.Contains(t, stdout, "ago")
42+
}
43+
44+
func TestAccountList_JSONOutput(t *testing.T) {
45+
env := testutil.NewTestEnv(t)
46+
47+
env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{
48+
Items: []*accountv1.Account{
49+
{Id: "acct-json", Name: "Test Account"},
50+
},
51+
}, nil)
52+
53+
stdout, _, err := testutil.Exec(t, env, "account", "list", "--json")
54+
require.NoError(t, err)
55+
56+
var result struct {
57+
Items []struct {
58+
ID string `json:"id"`
59+
Name string `json:"name"`
60+
} `json:"items"`
61+
}
62+
require.NoError(t, json.Unmarshal([]byte(stdout), &result))
63+
require.Len(t, result.Items, 1)
64+
assert.Equal(t, "acct-json", result.Items[0].ID)
65+
assert.Equal(t, "Test Account", result.Items[0].Name)
66+
}
67+
68+
func TestAccountList_BackendError(t *testing.T) {
69+
env := testutil.NewTestEnv(t)
70+
71+
env.AccountServer.ListAccountsCalls.Returns(nil, fmt.Errorf("service unavailable"))
72+
73+
_, _, err := testutil.Exec(t, env, "account", "list")
74+
require.Error(t, err)
75+
}
76+
77+
func TestAccountList_Empty(t *testing.T) {
78+
env := testutil.NewTestEnv(t)
79+
80+
env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{}, nil)
81+
82+
stdout, _, err := testutil.Exec(t, env, "account", "list")
83+
require.NoError(t, err)
84+
assert.Contains(t, stdout, "ID")
85+
assert.Contains(t, stdout, "NAME")
86+
}

0 commit comments

Comments
 (0)