Skip to content

Commit 54e78de

Browse files
authored
feat: add cluster logs command (#94)
1 parent 7645ae1 commit 54e78de

6 files changed

Lines changed: 365 additions & 0 deletions

File tree

internal/cmd/cluster/cluster.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func NewCommand(s *state.State) *cobra.Command {
2626
newVersionCommand(s),
2727
newKeyCommand(s),
2828
newScaleCommand(s),
29+
newLogsCommand(s),
2930
)
3031
return cmd
3132
}

internal/cmd/cluster/logs.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package cluster
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"time"
7+
8+
"github.com/spf13/cobra"
9+
"google.golang.org/protobuf/types/known/timestamppb"
10+
11+
monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1"
12+
13+
"github.com/qdrant/qcloud-cli/internal/cmd/base"
14+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
15+
"github.com/qdrant/qcloud-cli/internal/cmd/output"
16+
"github.com/qdrant/qcloud-cli/internal/cmd/util"
17+
"github.com/qdrant/qcloud-cli/internal/state"
18+
)
19+
20+
func newLogsCommand(s *state.State) *cobra.Command {
21+
cmd := base.DescribeCmd[*monitoringv1.GetClusterLogsResponse]{
22+
Use: "logs <cluster-id>",
23+
Short: "Retrieve logs for a cluster",
24+
Args: util.ExactArgs(1, "a cluster ID"),
25+
Example: `# Get logs for a cluster
26+
qcloud cluster logs abc-123
27+
28+
# Get logs since a specific date
29+
qcloud cluster logs abc-123 --since 2024-01-01
30+
31+
# Get logs in a specific time range
32+
qcloud cluster logs abc-123 --since 2024-01-01T00:00:00Z --until 2024-01-02T00:00:00Z
33+
34+
# Get logs in JSON format
35+
qcloud cluster logs abc-123 --json`,
36+
Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*monitoringv1.GetClusterLogsResponse, error) {
37+
ctx := cmd.Context()
38+
client, err := s.Client(ctx)
39+
if err != nil {
40+
return nil, err
41+
}
42+
43+
accountID, err := s.AccountID()
44+
if err != nil {
45+
return nil, err
46+
}
47+
48+
req := &monitoringv1.GetClusterLogsRequest{
49+
AccountId: accountID,
50+
ClusterId: args[0],
51+
}
52+
53+
if cmd.Flags().Changed("since") {
54+
sinceStr, _ := cmd.Flags().GetString("since")
55+
t, err := parseLogTime(sinceStr)
56+
if err != nil {
57+
return nil, fmt.Errorf("invalid --since %q: must be RFC3339 or YYYY-MM-DD", sinceStr)
58+
}
59+
req.Since = timestamppb.New(t)
60+
}
61+
62+
if cmd.Flags().Changed("until") {
63+
untilStr, _ := cmd.Flags().GetString("until")
64+
t, err := parseLogTime(untilStr)
65+
if err != nil {
66+
return nil, fmt.Errorf("invalid --until %q: must be RFC3339 or YYYY-MM-DD", untilStr)
67+
}
68+
req.Until = timestamppb.New(t)
69+
}
70+
71+
resp, err := client.Monitoring().GetClusterLogs(ctx, req)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to get cluster logs: %w", err)
74+
}
75+
return resp, nil
76+
},
77+
PrintText: func(cmd *cobra.Command, w io.Writer, resp *monitoringv1.GetClusterLogsResponse) error {
78+
timestamps, _ := cmd.Flags().GetBool("timestamps")
79+
for _, entry := range resp.GetItems() {
80+
if timestamps {
81+
fmt.Fprintf(w, "%s %s\n", output.FullDateTime(entry.GetTimestamp().AsTime()), entry.GetMessage())
82+
} else {
83+
fmt.Fprintln(w, entry.GetMessage())
84+
}
85+
}
86+
return nil
87+
},
88+
ValidArgsFunction: completion.ClusterIDCompletion(s),
89+
}.CobraCommand(s)
90+
91+
cmd.Flags().StringP("since", "s", "", "Start time for logs (RFC3339 or YYYY-MM-DD, default: 3 days ago)")
92+
cmd.Flags().StringP("until", "u", "", "End time for logs (RFC3339 or YYYY-MM-DD, default: now)")
93+
cmd.Flags().BoolP("timestamps", "t", false, "Prepend each log line with its timestamp")
94+
95+
return cmd
96+
}
97+
98+
func parseLogTime(s string) (time.Time, error) {
99+
t, err := time.Parse(time.RFC3339, s)
100+
if err == nil {
101+
return t, nil
102+
}
103+
return time.Parse("2006-01-02", s)
104+
}

internal/cmd/cluster/logs_test.go

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package cluster_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"google.golang.org/protobuf/types/known/timestamppb"
10+
11+
monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1"
12+
13+
"github.com/qdrant/qcloud-cli/internal/testutil"
14+
)
15+
16+
func logEntry(ts time.Time, msg string) *monitoringv1.LogEntry {
17+
return &monitoringv1.LogEntry{
18+
Timestamp: timestamppb.New(ts),
19+
Message: msg,
20+
}
21+
}
22+
23+
func TestClusterLogs_TextOutput(t *testing.T) {
24+
env := testutil.NewTestEnv(t)
25+
26+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{
27+
Items: []*monitoringv1.LogEntry{
28+
logEntry(time.Now(), "Starting Qdrant server"),
29+
logEntry(time.Now(), "Loaded 3 collections"),
30+
},
31+
}, nil)
32+
33+
stdout, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster")
34+
require.NoError(t, err)
35+
36+
assert.Contains(t, stdout, "Starting Qdrant server")
37+
assert.Contains(t, stdout, "Loaded 3 collections")
38+
}
39+
40+
func TestClusterLogs_NoTimestampByDefault(t *testing.T) {
41+
env := testutil.NewTestEnv(t)
42+
43+
ts := time.Date(2024, 1, 15, 10, 23, 45, 0, time.UTC)
44+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{
45+
Items: []*monitoringv1.LogEntry{
46+
logEntry(ts, "some message"),
47+
},
48+
}, nil)
49+
50+
stdout, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster")
51+
require.NoError(t, err)
52+
53+
assert.Contains(t, stdout, "some message")
54+
assert.NotContains(t, stdout, "2024-01-15")
55+
}
56+
57+
func TestClusterLogs_TimestampsFlag(t *testing.T) {
58+
env := testutil.NewTestEnv(t)
59+
60+
ts := time.Date(2024, 1, 15, 10, 23, 45, 0, time.UTC)
61+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{
62+
Items: []*monitoringv1.LogEntry{
63+
logEntry(ts, "some message"),
64+
},
65+
}, nil)
66+
67+
stdout, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster", "--timestamps")
68+
require.NoError(t, err)
69+
70+
assert.Contains(t, stdout, "2024-01-15 10:23:45 UTC")
71+
assert.Contains(t, stdout, "some message")
72+
}
73+
74+
func TestClusterLogs_TimestampsShorthand(t *testing.T) {
75+
env := testutil.NewTestEnv(t)
76+
77+
ts := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
78+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{
79+
Items: []*monitoringv1.LogEntry{
80+
logEntry(ts, "shorthand check"),
81+
},
82+
}, nil)
83+
84+
stdout, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster", "-t")
85+
require.NoError(t, err)
86+
87+
assert.Contains(t, stdout, "2024-06-01")
88+
assert.Contains(t, stdout, "shorthand check")
89+
}
90+
91+
func TestClusterLogs_JSONOutput(t *testing.T) {
92+
env := testutil.NewTestEnv(t)
93+
94+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{
95+
Items: []*monitoringv1.LogEntry{
96+
logEntry(time.Now(), "json log line"),
97+
},
98+
}, nil)
99+
100+
stdout, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster", "--json")
101+
require.NoError(t, err)
102+
103+
assert.Contains(t, stdout, `"items"`)
104+
assert.Contains(t, stdout, `"message"`)
105+
assert.Contains(t, stdout, "json log line")
106+
}
107+
108+
func TestClusterLogs_EmptyResponse(t *testing.T) {
109+
env := testutil.NewTestEnv(t)
110+
111+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{}, nil)
112+
113+
stdout, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster")
114+
require.NoError(t, err)
115+
116+
assert.Empty(t, stdout)
117+
}
118+
119+
func TestClusterLogs_AccountIDPassedToServer(t *testing.T) {
120+
env := testutil.NewTestEnv(t)
121+
122+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{}, nil)
123+
124+
_, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster")
125+
require.NoError(t, err)
126+
127+
req, ok := env.MonitoringServer.GetClusterLogsCalls.Last()
128+
require.True(t, ok)
129+
assert.Equal(t, "test-account-id", req.GetAccountId())
130+
}
131+
132+
func TestClusterLogs_ClusterIDPassedToServer(t *testing.T) {
133+
env := testutil.NewTestEnv(t)
134+
135+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{}, nil)
136+
137+
_, _, err := testutil.Exec(t, env, "cluster", "logs", "target-cluster")
138+
require.NoError(t, err)
139+
140+
req, ok := env.MonitoringServer.GetClusterLogsCalls.Last()
141+
require.True(t, ok)
142+
assert.Equal(t, "target-cluster", req.GetClusterId())
143+
}
144+
145+
func TestClusterLogs_SinceFlag_RFC3339(t *testing.T) {
146+
env := testutil.NewTestEnv(t)
147+
148+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{}, nil)
149+
150+
_, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster", "--since", "2024-01-01T00:00:00Z")
151+
require.NoError(t, err)
152+
153+
req, ok := env.MonitoringServer.GetClusterLogsCalls.Last()
154+
require.True(t, ok)
155+
require.NotNil(t, req.GetSince())
156+
assert.Equal(t, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), req.GetSince().AsTime())
157+
}
158+
159+
func TestClusterLogs_SinceFlag_DateOnly(t *testing.T) {
160+
env := testutil.NewTestEnv(t)
161+
162+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{}, nil)
163+
164+
_, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster", "-s", "2024-03-15")
165+
require.NoError(t, err)
166+
167+
req, ok := env.MonitoringServer.GetClusterLogsCalls.Last()
168+
require.True(t, ok)
169+
require.NotNil(t, req.GetSince())
170+
assert.Equal(t, time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC), req.GetSince().AsTime())
171+
}
172+
173+
func TestClusterLogs_UntilFlag(t *testing.T) {
174+
env := testutil.NewTestEnv(t)
175+
176+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{}, nil)
177+
178+
_, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster", "-u", "2024-02-01T12:00:00Z")
179+
require.NoError(t, err)
180+
181+
req, ok := env.MonitoringServer.GetClusterLogsCalls.Last()
182+
require.True(t, ok)
183+
require.NotNil(t, req.GetUntil())
184+
assert.Equal(t, time.Date(2024, 2, 1, 12, 0, 0, 0, time.UTC), req.GetUntil().AsTime())
185+
}
186+
187+
func TestClusterLogs_NoSinceByDefault(t *testing.T) {
188+
env := testutil.NewTestEnv(t)
189+
190+
env.MonitoringServer.GetClusterLogsCalls.Returns(&monitoringv1.GetClusterLogsResponse{}, nil)
191+
192+
_, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster")
193+
require.NoError(t, err)
194+
195+
req, ok := env.MonitoringServer.GetClusterLogsCalls.Last()
196+
require.True(t, ok)
197+
assert.Nil(t, req.GetSince())
198+
assert.Nil(t, req.GetUntil())
199+
}
200+
201+
func TestClusterLogs_InvalidSince(t *testing.T) {
202+
env := testutil.NewTestEnv(t)
203+
204+
_, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster", "--since", "not-a-date")
205+
require.Error(t, err)
206+
}
207+
208+
func TestClusterLogs_InvalidUntil(t *testing.T) {
209+
env := testutil.NewTestEnv(t)
210+
211+
_, _, err := testutil.Exec(t, env, "cluster", "logs", "my-cluster", "--until", "not-a-date")
212+
require.Error(t, err)
213+
}
214+
215+
func TestClusterLogs_MissingArg(t *testing.T) {
216+
env := testutil.NewTestEnv(t)
217+
218+
_, _, err := testutil.Exec(t, env, "cluster", "logs")
219+
require.Error(t, err)
220+
}

internal/qcloudapi/client.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
1313
clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1"
1414
hybridv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/hybrid/v1"
15+
monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1"
1516
platformv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/platform/v1"
1617
)
1718

@@ -24,6 +25,7 @@ type Client struct {
2425
databaseApiKey clusterauthv2.DatabaseApiKeyServiceClient
2526
backup backupv1.BackupServiceClient
2627
hybrid hybridv1.HybridCloudServiceClient
28+
monitoring monitoringv1.MonitoringServiceClient
2729
}
2830

2931
// New creates a new gRPC client connected to the given endpoint with the given API key.
@@ -54,6 +56,7 @@ func newFromConn(conn *grpc.ClientConn) *Client {
5456
databaseApiKey: clusterauthv2.NewDatabaseApiKeyServiceClient(conn),
5557
backup: backupv1.NewBackupServiceClient(conn),
5658
hybrid: hybridv1.NewHybridCloudServiceClient(conn),
59+
monitoring: monitoringv1.NewMonitoringServiceClient(conn),
5760
}
5861
}
5962

@@ -87,6 +90,11 @@ func (c *Client) Hybrid() hybridv1.HybridCloudServiceClient {
8790
return c.hybrid
8891
}
8992

93+
// Monitoring returns the MonitoringService gRPC client.
94+
func (c *Client) Monitoring() monitoringv1.MonitoringServiceClient {
95+
return c.monitoring
96+
}
97+
9098
// Close closes the underlying gRPC connection.
9199
func (c *Client) Close() error {
92100
return c.conn.Close()
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package testutil
2+
3+
import (
4+
"context"
5+
6+
monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1"
7+
)
8+
9+
// FakeMonitoringService is a test fake that implements MonitoringServiceServer.
10+
// Use the *Calls fields to configure responses and inspect captured requests.
11+
type FakeMonitoringService struct {
12+
monitoringv1.UnimplementedMonitoringServiceServer
13+
14+
GetClusterLogsCalls MethodSpy[*monitoringv1.GetClusterLogsRequest, *monitoringv1.GetClusterLogsResponse]
15+
}
16+
17+
// GetClusterLogs records the call and dispatches via GetClusterLogsCalls.
18+
func (f *FakeMonitoringService) GetClusterLogs(ctx context.Context, req *monitoringv1.GetClusterLogsRequest) (*monitoringv1.GetClusterLogsResponse, error) {
19+
f.GetClusterLogsCalls.record(req)
20+
return f.GetClusterLogsCalls.dispatch(ctx, req, f.UnimplementedMonitoringServiceServer.GetClusterLogs)
21+
}

0 commit comments

Comments
 (0)