Skip to content

Commit c27f564

Browse files
authored
chore: access command root so that subcommands can be worked in parallel (#100)
* chore: add shared infrastructure for access commands Adds base command helpers, auth API client methods, test utilities, and key ID completion support needed by the access key commands.
1 parent e24fa50 commit c27f564

File tree

11 files changed

+143
-0
lines changed

11 files changed

+143
-0
lines changed

AGENTS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,33 @@ If make lint fails from formatting problems, use `make format` to fix them.
5050

5151
## Conventions
5252

53+
### Long descriptions and examples — mandatory
54+
55+
Every leaf command and group command **must** have a `Long` description and an `Example` block.
56+
57+
**`Long`:**
58+
- First line expands the `Short` description into a full sentence.
59+
- Blank line, then one or two paragraphs explaining behaviour, use cases, and important caveats.
60+
- Use the proto service/message comments as the authoritative source of truth for what a resource or operation does.
61+
- Do NOT describe individual flags — only document unusual or non-obvious flag interactions.
62+
63+
**`Example`:**
64+
- One example per meaningful use case (basic call, common flag combinations, scripting).
65+
- Prefix every line with `# ` comment explaining what the example does.
66+
- Real command invocations with plausible IDs/values.
67+
68+
All five base types (`ListCmd`, `DescribeCmd`, `CreateCmd`, `UpdateCmd`, `Cmd`) expose `Long` and `Example` as top-level struct fields. Never set them inside `BaseCobraCommand()`.
69+
70+
### Tests — mandatory
71+
72+
Every new command package **must** ship tests. This is not optional.
73+
74+
- Place tests in `internal/cmd/<group>/` as `<file>_test.go` using `package <group>_test`.
75+
- Use `testutil.NewTestEnv` + `testutil.Exec` — never call command functions directly.
76+
- When adding a new gRPC service, also add a `fake_<service>.go` in `internal/testutil/` and register it in `server.go` / `TestEnv`.
77+
- Cover: table output (assert header columns + key values), JSON output (unmarshal and assert), request fields sent to server, backend errors (use `Returns(nil, fmt.Errorf(...))` and assert `require.Error`), input errors (missing args, wrong flags).
78+
- Run `make test` before declaring done.
79+
5380
### Subcommand pattern
5481

5582
Each subcommand group lives in `internal/cmd/<group>/`:

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/access"
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.KeyEndpoint, cmd.PersistentFlags().Lookup("endpoint"))
6667

6768
cmd.AddCommand(version.NewCommand(s))
69+
cmd.AddCommand(access.NewCommand(s))
6870
cmd.AddCommand(cluster.NewCommand(s))
6971
cmd.AddCommand(cloudprovider.NewCommand(s))
7072
cmd.AddCommand(cloudregion.NewCommand(s))

internal/cmd/access/access.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package access
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/qdrant/qcloud-cli/internal/state"
7+
)
8+
9+
// NewCommand creates the access command group.
10+
func NewCommand(s *state.State) *cobra.Command {
11+
cmd := &cobra.Command{
12+
Use: "access",
13+
Short: "Manage access to Qdrant Cloud",
14+
Long: `Manage access settings for the Qdrant Cloud account.`,
15+
Args: cobra.NoArgs,
16+
}
17+
return cmd
18+
}

internal/cmd/base/cmd.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
// return a resource. Use for delete, wait, use, set, and similar operations.
1111
type Cmd struct {
1212
BaseCobraCommand func() *cobra.Command
13+
Long string
1314
Example string
1415
Run func(s *state.State, cmd *cobra.Command, args []string) error
1516
ValidArgsFunction func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)
@@ -18,6 +19,9 @@ type Cmd struct {
1819
// CobraCommand builds a cobra.Command from this Cmd.
1920
func (gc Cmd) CobraCommand(s *state.State) *cobra.Command {
2021
cmd := gc.BaseCobraCommand()
22+
if gc.Long != "" {
23+
cmd.Long = gc.Long
24+
}
2125
cmd.Example = gc.Example
2226
cmd.RunE = func(cmd *cobra.Command, args []string) error {
2327
return gc.Run(s, cmd, args)

internal/cmd/base/create.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
// Read flags in Run via cmd.Flags().GetString() etc. — do not use bound vars.
1515
type CreateCmd[T any] struct {
1616
BaseCobraCommand func() *cobra.Command
17+
Long string
1718
Example string
1819
Run func(s *state.State, cmd *cobra.Command, args []string) (T, error)
1920
PrintResource func(cmd *cobra.Command, out io.Writer, resource T)
@@ -23,6 +24,9 @@ type CreateCmd[T any] struct {
2324
// CobraCommand builds a cobra.Command from this CreateCmd.
2425
func (cc CreateCmd[T]) CobraCommand(s *state.State) *cobra.Command {
2526
cmd := cc.BaseCobraCommand()
27+
if cc.Long != "" {
28+
cmd.Long = cc.Long
29+
}
2630
cmd.Example = cc.Example
2731
cmd.RunE = func(cmd *cobra.Command, args []string) error {
2832
resource, err := cc.Run(s, cmd, args)

internal/cmd/base/list.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
type ListCmd[T any] struct {
1515
Use string
1616
Short string
17+
Long string
1718
Example string
1819
Fetch func(s *state.State, cmd *cobra.Command) (T, error)
1920
PrintText func(cmd *cobra.Command, out io.Writer, resp T) error
@@ -25,6 +26,7 @@ func (lc ListCmd[T]) CobraCommand(s *state.State) *cobra.Command {
2526
cmd := &cobra.Command{
2627
Use: lc.Use,
2728
Short: lc.Short,
29+
Long: lc.Long,
2830
Example: lc.Example,
2931
Args: cobra.NoArgs,
3032
RunE: func(cmd *cobra.Command, args []string) error {

internal/cmd/base/update.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
// Read flags in Update via cmd.Flags().GetString() etc. — do not use bound vars.
1616
type UpdateCmd[T any] struct {
1717
BaseCobraCommand func() *cobra.Command
18+
Long string
1819
Example string
1920
Fetch func(s *state.State, cmd *cobra.Command, args []string) (T, error)
2021
Update func(s *state.State, cmd *cobra.Command, resource T) (T, error)
@@ -25,6 +26,9 @@ type UpdateCmd[T any] struct {
2526
// CobraCommand builds a cobra.Command from this UpdateCmd.
2627
func (uc UpdateCmd[T]) CobraCommand(s *state.State) *cobra.Command {
2728
cmd := uc.BaseCobraCommand()
29+
if uc.Long != "" {
30+
cmd.Long = uc.Long
31+
}
2832
cmd.Example = uc.Example
2933
cmd.RunE = func(cmd *cobra.Command, args []string) error {
3034
resource, err := uc.Fetch(s, cmd, args)

internal/cmd/completion/completion.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package completion
33
import (
44
"github.com/spf13/cobra"
55

6+
authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1"
67
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
78
clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1"
89
platformv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/platform/v1"
@@ -75,6 +76,39 @@ func CloudRegionCompletion(s *state.State) func(*cobra.Command, []string, string
7576
}
7677
}
7778

79+
// ManagementKeyIDCompletion returns a ValidArgsFunction that completes management key IDs.
80+
func ManagementKeyIDCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
81+
return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
82+
if len(args) > 0 {
83+
return nil, cobra.ShellCompDirectiveNoFileComp
84+
}
85+
86+
ctx := cmd.Context()
87+
client, err := s.Client(ctx)
88+
if err != nil {
89+
return nil, cobra.ShellCompDirectiveError
90+
}
91+
92+
accountID, err := s.AccountID()
93+
if err != nil {
94+
return nil, cobra.ShellCompDirectiveError
95+
}
96+
97+
resp, err := client.Auth().ListManagementKeys(ctx, &authv1.ListManagementKeysRequest{
98+
AccountId: accountID,
99+
})
100+
if err != nil {
101+
return nil, cobra.ShellCompDirectiveError
102+
}
103+
104+
completions := make([]string, 0, len(resp.GetItems()))
105+
for _, k := range resp.GetItems() {
106+
completions = append(completions, k.GetId()+"\t"+k.GetPrefix())
107+
}
108+
return completions, cobra.ShellCompDirectiveNoFileComp
109+
}
110+
}
111+
78112
// BackupIDCompletion returns a ValidArgsFunction that completes backup IDs.
79113
func BackupIDCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
80114
return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {

internal/qcloudapi/client.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"google.golang.org/grpc/credentials"
88
"google.golang.org/grpc/metadata"
99

10+
authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1"
1011
bookingv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/booking/v1"
1112
clusterauthv2 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/auth/v2"
1213
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
@@ -26,6 +27,7 @@ type Client struct {
2627
backup backupv1.BackupServiceClient
2728
hybrid hybridv1.HybridCloudServiceClient
2829
monitoring monitoringv1.MonitoringServiceClient
30+
auth authv1.AuthServiceClient
2931
}
3032

3133
// New creates a new gRPC client connected to the given endpoint with the given API key.
@@ -57,6 +59,7 @@ func newFromConn(conn *grpc.ClientConn) *Client {
5759
backup: backupv1.NewBackupServiceClient(conn),
5860
hybrid: hybridv1.NewHybridCloudServiceClient(conn),
5961
monitoring: monitoringv1.NewMonitoringServiceClient(conn),
62+
auth: authv1.NewAuthServiceClient(conn),
6063
}
6164
}
6265

@@ -95,6 +98,11 @@ func (c *Client) Monitoring() monitoringv1.MonitoringServiceClient {
9598
return c.monitoring
9699
}
97100

101+
// Auth returns the AuthService gRPC client.
102+
func (c *Client) Auth() authv1.AuthServiceClient {
103+
return c.auth
104+
}
105+
98106
// Close closes the underlying gRPC connection.
99107
func (c *Client) Close() error {
100108
return c.conn.Close()

internal/testutil/fake_auth.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package testutil
2+
3+
import (
4+
"context"
5+
6+
authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1"
7+
)
8+
9+
// FakeAuthService is a test fake that implements AuthServiceServer.
10+
// Use the *Calls fields to configure responses and inspect captured requests.
11+
type FakeAuthService struct {
12+
authv1.UnimplementedAuthServiceServer
13+
14+
ListManagementKeysCalls MethodSpy[*authv1.ListManagementKeysRequest, *authv1.ListManagementKeysResponse]
15+
CreateManagementKeyCalls MethodSpy[*authv1.CreateManagementKeyRequest, *authv1.CreateManagementKeyResponse]
16+
DeleteManagementKeyCalls MethodSpy[*authv1.DeleteManagementKeyRequest, *authv1.DeleteManagementKeyResponse]
17+
}
18+
19+
// ListManagementKeys records the call and dispatches via ListManagementKeysCalls.
20+
func (f *FakeAuthService) ListManagementKeys(ctx context.Context, req *authv1.ListManagementKeysRequest) (*authv1.ListManagementKeysResponse, error) {
21+
f.ListManagementKeysCalls.record(req)
22+
return f.ListManagementKeysCalls.dispatch(ctx, req, f.UnimplementedAuthServiceServer.ListManagementKeys)
23+
}
24+
25+
// CreateManagementKey records the call and dispatches via CreateManagementKeyCalls.
26+
func (f *FakeAuthService) CreateManagementKey(ctx context.Context, req *authv1.CreateManagementKeyRequest) (*authv1.CreateManagementKeyResponse, error) {
27+
f.CreateManagementKeyCalls.record(req)
28+
return f.CreateManagementKeyCalls.dispatch(ctx, req, f.UnimplementedAuthServiceServer.CreateManagementKey)
29+
}
30+
31+
// DeleteManagementKey records the call and dispatches via DeleteManagementKeyCalls.
32+
func (f *FakeAuthService) DeleteManagementKey(ctx context.Context, req *authv1.DeleteManagementKeyRequest) (*authv1.DeleteManagementKeyResponse, error) {
33+
f.DeleteManagementKeyCalls.record(req)
34+
return f.DeleteManagementKeyCalls.dispatch(ctx, req, f.UnimplementedAuthServiceServer.DeleteManagementKey)
35+
}

0 commit comments

Comments
 (0)