diff --git a/internal/cli/gitaly/cluster_output.go b/internal/cli/gitaly/cluster_output.go new file mode 100644 index 0000000000000000000000000000000000000000..a262f71cafc213116094aeab3e6f1aebdf90ee90 --- /dev/null +++ b/internal/cli/gitaly/cluster_output.go @@ -0,0 +1,375 @@ +package gitaly + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "text/tabwriter" + + "gitlab.com/gitlab-org/gitaly/v18/proto/go/gitalypb" + "google.golang.org/protobuf/encoding/protojson" +) + +// ClusterInfoOutput represents the structured output for the cluster info command. +// This type defines the contract for JSON output, making it easier to maintain +// compatibility with clients consuming the JSON data. +type ClusterInfoOutput struct { + ClusterInfo *gitalypb.RaftClusterInfoResponse + Partitions []*gitalypb.GetPartitionsResponse +} + +// ToJSON outputs the cluster info in JSON format +func (o *ClusterInfoOutput) ToJSON(writer io.Writer) error { + // Sort partitions by partition key for consistent output + if len(o.Partitions) > 0 { + sortPartitionsByKey(o.Partitions) + } + + // Configure protojson marshaler + marshaler := protojson.MarshalOptions{ + EmitUnpopulated: false, // Omit fields with default values + UseProtoNames: false, // Use lowerCamelCase JSON field names + } + + // Convert protobuf to JSON, then to map for standard JSON marshaling + clusterInfoBytes, err := marshaler.Marshal(o.ClusterInfo) + if err != nil { + return fmt.Errorf("failed to marshal cluster info: %w", err) + } + + var clusterInfoMap map[string]interface{} + if err := json.Unmarshal(clusterInfoBytes, &clusterInfoMap); err != nil { + return fmt.Errorf("failed to unmarshal cluster info: %w", err) + } + + // Build the output structure + output := map[string]interface{}{ + "clusterInfo": clusterInfoMap, + } + + // Add partitions if present + if len(o.Partitions) > 0 { + var partitionsArray []map[string]interface{} + for _, partition := range o.Partitions { + partitionBytes, err := marshaler.Marshal(partition) + if err != nil { + return fmt.Errorf("failed to marshal partition: %w", err) + } + + var partitionMap map[string]interface{} + if err := json.Unmarshal(partitionBytes, &partitionMap); err != nil { + return fmt.Errorf("failed to unmarshal partition: %w", err) + } + + partitionsArray = append(partitionsArray, partitionMap) + } + output["partitions"] = partitionsArray + } + + // Marshal with indentation for human-friendly output + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON output: %w", err) + } + + fmt.Fprintf(writer, "%s\n", string(jsonBytes)) + return nil +} + +// ToText outputs the cluster info in human-readable text format +func (o *ClusterInfoOutput) ToText(writer io.Writer, colorOutput *colorOutput, storage string, listPartitions bool) error { + fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("=== Gitaly Cluster Information ===")) + + // Display cluster statistics overview + if o.ClusterInfo.GetStatistics() != nil { + if err := displayClusterStatistics(writer, o.ClusterInfo.GetStatistics(), storage, colorOutput); err != nil { + return err + } + } + + // Display partition overview table if requested + if listPartitions || storage != "" { + if len(o.Partitions) > 0 { + // Sort partitions by partition key for consistent output + sortPartitionsByKey(o.Partitions) + + return displayPartitionTable(writer, o.Partitions, storage, colorOutput) + } else if storage != "" { + fmt.Fprintf(writer, "No partitions found for storage: %s\n", storage) + } + } else { + // Suggest showing partitions if not displayed + fmt.Fprintf(writer, "%s\n", colorOutput.formatInfo("Use --list-partitions to display partition overview table.")) + } + + return nil +} + +// PartitionDetailsOutput represents the structured output for the get-partition command. +// This type defines the contract for JSON output, making it easier to maintain +// compatibility with clients consuming the JSON data. +type PartitionDetailsOutput struct { + Partitions []*gitalypb.GetPartitionsResponse +} + +// ToJSON outputs the partition details in JSON format +func (o *PartitionDetailsOutput) ToJSON(writer io.Writer) error { + // Sort partitions by partition key for consistent output + if len(o.Partitions) > 0 { + sortPartitionsByKey(o.Partitions) + } + + // Configure protojson marshaler + marshaler := protojson.MarshalOptions{ + EmitUnpopulated: false, // Omit fields with default values + UseProtoNames: false, // Use lowerCamelCase JSON field names + } + + // Build the partitions array + var partitionsArray []map[string]interface{} + for _, partition := range o.Partitions { + partitionBytes, err := marshaler.Marshal(partition) + if err != nil { + return fmt.Errorf("failed to marshal partition: %w", err) + } + + var partitionMap map[string]interface{} + if err := json.Unmarshal(partitionBytes, &partitionMap); err != nil { + return fmt.Errorf("failed to unmarshal partition: %w", err) + } + + partitionsArray = append(partitionsArray, partitionMap) + } + + // Build the output structure + output := map[string]interface{}{ + "partitions": partitionsArray, + } + + // Marshal with indentation for human-friendly output + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON output: %w", err) + } + + fmt.Fprintf(writer, "%s\n", string(jsonBytes)) + return nil +} + +// ToText outputs the partition details in human-readable text format +func (o *PartitionDetailsOutput) ToText(writer io.Writer, colorOutput *colorOutput, partitionKey, relativePath string) error { + // Display detailed partition information + if len(o.Partitions) > 0 { + // Sort partitions by partition key for consistent output + sortPartitionsByKey(o.Partitions) + + if relativePath != "" { + fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader(fmt.Sprintf("=== Partition Details for Repository: %s ===", relativePath))) + } else if partitionKey != "" { + fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader(fmt.Sprintf("=== Partition Details for Key: %s ===", partitionKey))) + } + + for i, partition := range o.Partitions { + if i > 0 { + fmt.Fprintf(writer, "\n") + } + + fmt.Fprintf(writer, "Partition: %s\n\n", colorOutput.formatInfo(partition.GetPartitionKey().GetValue())) + + // Display replicas in tabular format + if len(partition.GetReplicas()) > 0 { + tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) + + fmt.Fprintf(tw, "STORAGE\tROLE\tHEALTH\tLAST INDEX\tMATCH INDEX\n") + fmt.Fprintf(tw, "-------\t----\t------\t----------\t-----------\n") + + for _, replica := range partition.GetReplicas() { + var role string + if replica.GetIsLeader() { + role = colorOutput.formatInfo("Leader") + } else { + role = "Follower" + } + var health string + if replica.GetIsHealthy() { + health = colorOutput.formatHealthy("Healthy") + } else { + health = colorOutput.formatUnhealthy("Unhealthy") + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\n", + replica.GetReplicaId().GetStorageName(), + role, + health, + replica.GetLastIndex(), + replica.GetMatchIndex()) + } + + _ = tw.Flush() + fmt.Fprintf(writer, "\n") + } + + // Display repositories in tabular format + if len(partition.GetRelativePaths()) > 0 { + fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("Repositories:")) + + tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) + + fmt.Fprintf(tw, "REPOSITORY PATH\n") + fmt.Fprintf(tw, "---------------\n") + + for _, path := range partition.GetRelativePaths() { + fmt.Fprintf(tw, "%s\n", path) + } + + _ = tw.Flush() + } + } + } else { + fmt.Fprintf(writer, "No partitions found matching the specified criteria.\n") + } + + return nil +} + +// displayClusterStatistics displays cluster-wide statistics in a readable format +func displayClusterStatistics(writer io.Writer, stats *gitalypb.ClusterStatistics, storageFilter string, colorOutput *colorOutput) error { + // Display overall cluster health at the top + partitionHealth := colorOutput.formatHealthStatus(int(stats.GetHealthyPartitions()), int(stats.GetTotalPartitions())) + replicaHealth := colorOutput.formatHealthStatus(int(stats.GetHealthyReplicas()), int(stats.GetTotalReplicas())) + + fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("=== Cluster Health Summary ===")) + fmt.Fprintf(writer, " Partitions: %s\n", partitionHealth) + fmt.Fprintf(writer, " Replicas: %s\n\n", replicaHealth) + + fmt.Fprintf(writer, "%s\n", colorOutput.formatHeader("=== Cluster Statistics ===")) + fmt.Fprintf(writer, " Total Partitions: %s\n", colorOutput.formatInfo(fmt.Sprintf("%d", stats.GetTotalPartitions()))) + fmt.Fprintf(writer, " Total Replicas: %s\n", colorOutput.formatInfo(fmt.Sprintf("%d", stats.GetTotalReplicas()))) + fmt.Fprintf(writer, " Healthy Partitions: %s\n", colorOutput.formatInfo(fmt.Sprintf("%d", stats.GetHealthyPartitions()))) + fmt.Fprintf(writer, " Healthy Replicas: %s\n", colorOutput.formatInfo(fmt.Sprintf("%d", stats.GetHealthyReplicas()))) + fmt.Fprintf(writer, "\n") + + if len(stats.GetStorageStats()) > 0 { + fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("=== Per-Storage Statistics ===")) + + // Filter storage names if a storage filter is specified + var storageNames []string + if storageFilter != "" { + // Only show the filtered storage if it exists + if _, exists := stats.GetStorageStats()[storageFilter]; exists { + storageNames = append(storageNames, storageFilter) + } + } else { + // Show all storages if no filter is specified + for storageName := range stats.GetStorageStats() { + storageNames = append(storageNames, storageName) + } + sort.Strings(storageNames) + } + + // Create table writer for storage statistics + tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) + + // Table headers + fmt.Fprintf(tw, "STORAGE\tLEADER COUNT\tREPLICA COUNT\n") + fmt.Fprintf(tw, "-------\t------------\t-------------\n") + + for _, storageName := range storageNames { + storageStat := stats.GetStorageStats()[storageName] + fmt.Fprintf(tw, "%s\t%d\t%d\n", + storageName, + storageStat.GetLeaderCount(), + storageStat.GetReplicaCount()) + } + + // Flush the table and add spacing + if err := tw.Flush(); err != nil { + return fmt.Errorf("failed to flush storage statistics table: %w", err) + } + fmt.Fprintf(writer, "\n") + } + return nil +} + +// displayPartitionTable displays partitions in a tabular format +func displayPartitionTable(writer io.Writer, partitions []*gitalypb.GetPartitionsResponse, storageFilter string, colorOutput *colorOutput) error { + fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("=== Partition Overview ===")) + + // Create table writer + tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) + + // Table headers + fmt.Fprintf(tw, "PARTITION KEY\tLEADER\tREPLICAS\tHEALTH\tLAST INDEX\tMATCH INDEX\tREPOSITORIES\n") + fmt.Fprintf(tw, "-------------\t------\t--------\t------\t----------\t-----------\t------------\n") + + for _, partition := range partitions { + // Find leader and collect replica info + leader := "None" + healthyReplicas := 0 + totalReplicas := len(partition.GetReplicas()) + var replicaStorages []string + var filteredReplicas []string + var leaderLastIndex, leaderMatchIndex uint64 + + for _, replica := range partition.GetReplicas() { + storageName := replica.GetReplicaId().GetStorageName() + if replica.GetIsLeader() { + leader = colorOutput.formatInfo(storageName) + leaderLastIndex = replica.GetLastIndex() + leaderMatchIndex = replica.GetMatchIndex() + } + if replica.GetIsHealthy() { + healthyReplicas++ + } + replicaStorages = append(replicaStorages, storageName) + + // Collect replicas matching storage filter + if storageFilter != "" && storageName == storageFilter { + filteredReplicas = append(filteredReplicas, storageName) + } + } + + // Format replica list showing storage names + replicasStr := strings.Join(replicaStorages, ", ") + if storageFilter != "" && len(filteredReplicas) > 0 { + // Show all replicas but highlight the filtered ones + replicasStr = fmt.Sprintf("%s (filtered: %s)", replicasStr, strings.Join(filteredReplicas, ", ")) + } + + // Format health status with color + var healthStr string + if healthyReplicas == totalReplicas && totalReplicas > 0 { + healthStr = colorOutput.formatHealthy(fmt.Sprintf("%d/%d", healthyReplicas, totalReplicas)) + } else if healthyReplicas == 0 { + healthStr = colorOutput.formatUnhealthy(fmt.Sprintf("%d/%d", healthyReplicas, totalReplicas)) + } else { + healthStr = colorOutput.formatWarning(fmt.Sprintf("%d/%d", healthyReplicas, totalReplicas)) + } + + // Format last index and match index + lastIndexStr := "N/A" + matchIndexStr := "N/A" + if leader != "None" { + lastIndexStr = fmt.Sprintf("%d", leaderLastIndex) + matchIndexStr = fmt.Sprintf("%d", leaderMatchIndex) + } + + // Format repository count + repoCount := len(partition.GetRelativePaths()) + repoStr := fmt.Sprintf("%d repos", repoCount) + + // Display full partition key + partitionKeyDisplay := partition.GetPartitionKey().GetValue() + + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + partitionKeyDisplay, leader, replicasStr, healthStr, lastIndexStr, matchIndexStr, repoStr) + } + + // Flush the partition table + if err := tw.Flush(); err != nil { + return fmt.Errorf("failed to flush partition overview table: %w", err) + } + + return nil +} diff --git a/internal/cli/gitaly/subcmd_cluster_get_partition.go b/internal/cli/gitaly/subcmd_cluster_get_partition.go index 972bec1d6ce1432b66d891303908be57e3f6cd40..f1745dc090d60983b7ff1b240aaa608583083f37 100644 --- a/internal/cli/gitaly/subcmd_cluster_get_partition.go +++ b/internal/cli/gitaly/subcmd_cluster_get_partition.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "io" - "text/tabwriter" "github.com/urfave/cli/v3" "gitlab.com/gitlab-org/gitaly/v18/proto/go/gitalypb" @@ -16,20 +14,24 @@ const ( flagGetPartitionPartitionKey = "partition-key" flagGetPartitionRelativePath = "relative-path" flagGetPartitionNoColor = "no-color" + flagGetPartitionFormat = "format" ) func newClusterGetPartitionCommand() *cli.Command { return &cli.Command{ Name: "get-partition", Usage: "display detailed partition information", - UsageText: `gitaly cluster get-partition --config [--partition-key ] [--relative-path ] + UsageText: `gitaly cluster get-partition --config [--partition-key ] [--relative-path ] [--format ] Examples: # Get detailed info for a specific partition by key (64-character SHA256 hex) gitaly cluster get-partition --config config.toml --partition-key abc123... # Get partition info for a specific repository path - gitaly cluster get-partition --config config.toml --relative-path @hashed/ab/cd/abcd...`, + gitaly cluster get-partition --config config.toml --relative-path @hashed/ab/cd/abcd... + + # Output as JSON for programmatic consumption + gitaly cluster get-partition --config config.toml --partition-key abc123... --format json`, Description: `Display detailed information about specific partitions including: - Partition key and replica topology - Leader/follower status for each replica @@ -38,7 +40,11 @@ Examples: Use --partition-key to filter by a specific partition key, or --relative-path to find the partition containing a specific repository. When using --relative-path, the output -shows the partition that contains the specified repository.`, +shows the partition that contains the specified repository. + +Output formats: + - text (default): Human-readable colored tables and detailed information + - json: Machine-readable JSON for automation and scripting`, Flags: []cli.Flag{ &cli.StringFlag{ Name: flagGetPartitionConfig, @@ -58,6 +64,11 @@ shows the partition that contains the specified repository.`, Name: flagGetPartitionNoColor, Usage: "disable colored output", }, + &cli.StringFlag{ + Name: flagGetPartitionFormat, + Usage: "output format: 'text' (default) or 'json'", + Value: "text", + }, }, Action: getPartitionAction, } @@ -70,9 +81,12 @@ func getPartitionAction(ctx context.Context, cmd *cli.Command) error { partitionKey := cmd.String(flagGetPartitionPartitionKey) relativePath := cmd.String(flagGetPartitionRelativePath) noColor := cmd.Bool(flagGetPartitionNoColor) + format := cmd.String(flagGetPartitionFormat) - // Configure color output - colorOutput := setupColorOutput(cmd.Writer, noColor) + // Validate format + if format != "text" && format != "json" { + return fmt.Errorf("invalid format %q: must be 'text' or 'json'", format) + } // Validate that at least one filter is provided if partitionKey == "" && relativePath == "" { @@ -96,12 +110,29 @@ func getPartitionAction(ctx context.Context, cmd *cli.Command) error { } defer cleanup() - // Display partition details - return displayPartitionDetails(ctx, cmd.Writer, raftClient, partitionKey, relativePath, colorOutput) + // Fetch partition data + partitions, err := fetchPartitionData(ctx, raftClient, partitionKey, relativePath) + if err != nil { + return err + } + + // Prepare output structure + output := &PartitionDetailsOutput{ + Partitions: partitions, + } + + // Output based on format + if format == "json" { + return output.ToJSON(cmd.Writer) + } + + // Configure color output for text format + colorOutput := setupColorOutput(cmd.Writer, noColor) + return output.ToText(cmd.Writer, colorOutput, partitionKey, relativePath) } -// displayPartitionDetails calls RPCs and displays detailed partition information -func displayPartitionDetails(ctx context.Context, writer io.Writer, client gitalypb.RaftServiceClient, partitionKey, relativePath string, colorOutput *colorOutput) error { +// fetchPartitionData retrieves partition details from the Raft service +func fetchPartitionData(ctx context.Context, client gitalypb.RaftServiceClient, partitionKey, relativePath string) ([]*gitalypb.GetPartitionsResponse, error) { // Get partition details using GetPartitions RPC partitionsReq := &gitalypb.GetPartitionsRequest{ IncludeRelativePaths: true, @@ -118,90 +149,14 @@ func displayPartitionDetails(ctx context.Context, writer io.Writer, client gital partitionsStream, err := client.GetPartitions(ctx, partitionsReq) if err != nil { - return fmt.Errorf("failed to retrieve partition information - verify server is running and Raft is enabled: %w", err) + return nil, fmt.Errorf("failed to retrieve partition information - verify server is running and Raft is enabled: %w", err) } - // Step 2: Collect all partition responses using helper function + // Collect all partition responses using helper function partitionResponses, err := collectPartitionResponses(partitionsStream) if err != nil { - return err - } - - // Step 3: Display results - return displayFormattedPartitionDetails(writer, partitionResponses, partitionKey, relativePath, colorOutput) -} - -// displayFormattedPartitionDetails displays detailed partition information -func displayFormattedPartitionDetails(writer io.Writer, partitions []*gitalypb.GetPartitionsResponse, partitionKey, relativePath string, colorOutput *colorOutput) error { - // Display detailed partition information - if len(partitions) > 0 { - // Sort partitions by partition key for consistent output - sortPartitionsByKey(partitions) - - if relativePath != "" { - fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader(fmt.Sprintf("=== Partition Details for Repository: %s ===", relativePath))) - } else if partitionKey != "" { - fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader(fmt.Sprintf("=== Partition Details for Key: %s ===", partitionKey))) - } - - for i, partition := range partitions { - if i > 0 { - fmt.Fprintf(writer, "\n") - } - - fmt.Fprintf(writer, "Partition: %s\n\n", colorOutput.formatInfo(partition.GetPartitionKey().GetValue())) - - // Display replicas in tabular format - if len(partition.GetReplicas()) > 0 { - tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) - - fmt.Fprintf(tw, "STORAGE\tROLE\tHEALTH\tLAST INDEX\tMATCH INDEX\n") - fmt.Fprintf(tw, "-------\t----\t------\t----------\t-----------\n") - - for _, replica := range partition.GetReplicas() { - var role string - if replica.GetIsLeader() { - role = colorOutput.formatInfo("Leader") - } else { - role = "Follower" - } - var health string - if replica.GetIsHealthy() { - health = colorOutput.formatHealthy("Healthy") - } else { - health = colorOutput.formatUnhealthy("Unhealthy") - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\n", - replica.GetReplicaId().GetStorageName(), - role, - health, - replica.GetLastIndex(), - replica.GetMatchIndex()) - } - - _ = tw.Flush() - fmt.Fprintf(writer, "\n") - } - - // Display repositories in tabular format - if len(partition.GetRelativePaths()) > 0 { - fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("Repositories:")) - - tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) - - fmt.Fprintf(tw, "REPOSITORY PATH\n") - fmt.Fprintf(tw, "---------------\n") - - for _, path := range partition.GetRelativePaths() { - fmt.Fprintf(tw, "%s\n", path) - } - - _ = tw.Flush() - } - } - } else { - fmt.Fprintf(writer, "No partitions found matching the specified criteria.\n") + return nil, err } - return nil + return partitionResponses, nil } diff --git a/internal/cli/gitaly/subcmd_cluster_get_partition_test.go b/internal/cli/gitaly/subcmd_cluster_get_partition_test.go index 29e190c1a274cc259ba8c6c5cc08c9f6f90095f5..e9f8e1b93ff9805023bca32d780cd1139f16f9e6 100644 --- a/internal/cli/gitaly/subcmd_cluster_get_partition_test.go +++ b/internal/cli/gitaly/subcmd_cluster_get_partition_test.go @@ -176,6 +176,174 @@ REPOSITORY PATH }, args: []string{"--partition-key", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, expectedOutput: `No partitions found matching the specified criteria. +`, + }, + { + name: "invalid format value", + setupServer: func(t *testing.T) (string, func()) { + cfg := testcfg.Build(t) + return testcfg.WriteTemporaryGitalyConfigFile(t, cfg), func() {} + }, + args: []string{"--partition-key", "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad", "--format", "yaml"}, + expectError: true, + expectedOutput: "invalid format \"yaml\": must be 'text' or 'json'", + }, + { + name: "get partition with JSON format and partition key", + setupServer: func(t *testing.T) (string, func()) { + return setupRaftServerForPartition(t, setupTestDataForPartition) + }, + args: []string{"--partition-key", "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad", "--format", "json"}, + expectedOutput: `{ + "partitions": [ + { + "clusterId": "test-cluster", + "index": "100", + "leaderId": "1", + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "relativePath": "@hashed/ab/cd/repo1.git", + "relativePaths": [ + "@hashed/ab/cd/repo1.git" + ], + "replicas": [ + { + "isHealthy": true, + "isLeader": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "1", + "metadata": { + "address": "gitaly-1.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-1", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "leader" + }, + { + "isHealthy": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "2", + "metadata": { + "address": "gitaly-2.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-2", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + }, + { + "isHealthy": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "3", + "metadata": { + "address": "gitaly-3.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-3", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + } + ], + "term": "5" + } + ] +} +`, + }, + { + name: "get partition with JSON format and relative path", + setupServer: func(t *testing.T) (string, func()) { + return setupRaftServerForPartition(t, setupTestDataForPartition) + }, + args: []string{"--relative-path", "@hashed/ab/cd/repo1.git", "--format", "json"}, + expectedOutput: `{ + "partitions": [ + { + "clusterId": "test-cluster", + "index": "100", + "leaderId": "1", + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "relativePath": "@hashed/ab/cd/repo1.git", + "relativePaths": [ + "@hashed/ab/cd/repo1.git" + ], + "replicas": [ + { + "isHealthy": true, + "isLeader": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "1", + "metadata": { + "address": "gitaly-1.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-1", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "leader" + }, + { + "isHealthy": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "2", + "metadata": { + "address": "gitaly-2.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-2", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + }, + { + "isHealthy": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "3", + "metadata": { + "address": "gitaly-3.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-3", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + } + ], + "term": "5" + } + ] +} `, }, } diff --git a/internal/cli/gitaly/subcmd_cluster_info.go b/internal/cli/gitaly/subcmd_cluster_info.go index 2abfe89b6830b9db0ab8c57359c2722feae94984..4c43eb7ba9d2167595bb0d6ae05b73fbd89f1e64 100644 --- a/internal/cli/gitaly/subcmd_cluster_info.go +++ b/internal/cli/gitaly/subcmd_cluster_info.go @@ -5,9 +5,6 @@ import ( "fmt" "io" "os" - "sort" - "strings" - "text/tabwriter" "github.com/fatih/color" "github.com/mattn/go-isatty" @@ -20,13 +17,14 @@ const ( flagClusterInfoStorage = "storage" flagClusterInfoListPartitions = "list-partitions" flagClusterInfoNoColor = "no-color" + flagClusterInfoFormat = "format" ) func newClusterInfoCommand() *cli.Command { return &cli.Command{ Name: "info", Usage: "display cluster statistics and overview", - UsageText: `gitaly cluster info --config [--list-partitions] [--storage ] + UsageText: `gitaly cluster info --config [--list-partitions] [--storage ] [--format ] Examples: # Show cluster statistics only (default) @@ -36,7 +34,13 @@ Examples: gitaly cluster info --config config.toml --list-partitions # Filter by storage (shows partition overview for that storage) - gitaly cluster info --config config.toml --storage storage-1 --list-partitions`, + gitaly cluster info --config config.toml --storage storage-1 --list-partitions + + # Output as JSON for programmatic consumption + gitaly cluster info --config config.toml --format json + + # Output JSON with partition details + gitaly cluster info --config config.toml --format json --list-partitions`, Description: `Display cluster-wide information including: - Cluster statistics (total partitions, replicas, health) - shown by default - Per-storage statistics (leader and replica counts) @@ -45,6 +49,10 @@ Examples: By default, only cluster statistics are displayed. Use --list-partitions to see a partition overview table. Use --storage to filter partitions by storage. +Output formats: + - text (default): Human-readable colored tables and statistics + - json: Machine-readable JSON for automation and scripting + For detailed partition information, use 'gitaly cluster get-partition'.`, Flags: []cli.Flag{ &cli.StringFlag{ @@ -65,6 +73,11 @@ For detailed partition information, use 'gitaly cluster get-partition'.`, Name: flagClusterInfoNoColor, Usage: "disable colored output", }, + &cli.StringFlag{ + Name: flagClusterInfoFormat, + Usage: "output format: 'text' (default) or 'json'", + Value: "text", + }, }, Action: clusterInfoAction, } @@ -77,9 +90,12 @@ func clusterInfoAction(ctx context.Context, cmd *cli.Command) error { storage := cmd.String(flagClusterInfoStorage) listPartitions := cmd.Bool(flagClusterInfoListPartitions) noColor := cmd.Bool(flagClusterInfoNoColor) + format := cmd.String(flagClusterInfoFormat) - // Configure color output - colorOutput := setupColorOutput(cmd.Writer, noColor) + // Validate format + if format != "text" && format != "json" { + return fmt.Errorf("invalid format %q: must be 'text' or 'json'", format) + } // Create Raft client using shared helper raftClient, cleanup, err := loadConfigAndCreateRaftClient(ctx, configPath) @@ -88,17 +104,35 @@ func clusterInfoAction(ctx context.Context, cmd *cli.Command) error { } defer cleanup() - // Display cluster info with optimized RPC calls - return displayClusterInfo(ctx, cmd.Writer, raftClient, storage, listPartitions, colorOutput) + // Fetch cluster data + clusterInfoResp, partitions, err := fetchClusterData(ctx, raftClient, storage, listPartitions) + if err != nil { + return err + } + + // Prepare output structure + output := &ClusterInfoOutput{ + ClusterInfo: clusterInfoResp, + Partitions: partitions, + } + + // Output based on format + if format == "json" { + return output.ToJSON(cmd.Writer) + } + + // Configure color output for text format + colorOutput := setupColorOutput(cmd.Writer, noColor) + return output.ToText(cmd.Writer, colorOutput, storage, listPartitions) } -// displayClusterInfo calls GetClusterInfo and optionally GetPartitions RPCs, processes the data, and displays the results -func displayClusterInfo(ctx context.Context, writer io.Writer, client gitalypb.RaftServiceClient, storage string, listPartitions bool, colorOutput *colorOutput) error { +// fetchClusterData retrieves cluster information and optionally partition details from the Raft service +func fetchClusterData(ctx context.Context, client gitalypb.RaftServiceClient, storage string, listPartitions bool) (*gitalypb.RaftClusterInfoResponse, []*gitalypb.GetPartitionsResponse, error) { // Step 1: Always get cluster statistics using GetClusterInfo RPC var clusterInfoReq *gitalypb.RaftClusterInfoRequest clusterInfoResp, err := client.GetClusterInfo(ctx, clusterInfoReq) if err != nil { - return fmt.Errorf("failed to retrieve cluster information - verify server is running and Raft is enabled: %w", err) + return nil, nil, fmt.Errorf("failed to retrieve cluster information - verify server is running and Raft is enabled: %w", err) } // Step 2: Only get partition details if needed (when --list-partitions is set or --storage filter is provided) @@ -120,188 +154,17 @@ func displayClusterInfo(ctx context.Context, writer io.Writer, client gitalypb.R partitionsStream, err := client.GetPartitions(ctx, partitionsReq) if err != nil { - return fmt.Errorf("failed to retrieve partition details - verify server is running and storage %q exists: %w", storage, err) + return nil, nil, fmt.Errorf("failed to retrieve partition details - verify server is running and storage %q exists: %w", storage, err) } // Collect all partition responses using helper function partitionResponses, err = collectPartitionResponses(partitionsStream) if err != nil { - return err - } - } - - // Step 3: Display results using the RPC responses - return displayFormattedResults(writer, clusterInfoResp, partitionResponses, storage, listPartitions, colorOutput) -} - -// displayFormattedResults displays cluster information using tabular format -func displayFormattedResults(writer io.Writer, clusterInfoResp *gitalypb.RaftClusterInfoResponse, partitions []*gitalypb.GetPartitionsResponse, storage string, listPartitions bool, colorOutput *colorOutput) error { - fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("=== Gitaly Cluster Information ===")) - - // Display cluster statistics overview - if clusterInfoResp.GetStatistics() != nil { - if err := displayClusterStatistics(writer, clusterInfoResp.GetStatistics(), storage, colorOutput); err != nil { - return err - } - } - - // Display partition overview table if requested - if listPartitions || storage != "" { - if len(partitions) > 0 { - // Sort partitions by partition key for consistent output - sortPartitionsByKey(partitions) - - return displayPartitionTable(writer, partitions, storage, colorOutput) - } else if storage != "" { - fmt.Fprintf(writer, "No partitions found for storage: %s\n", storage) + return nil, nil, err } - } else { - // Suggest showing partitions if not displayed - fmt.Fprintf(writer, "%s\n", colorOutput.formatInfo("Use --list-partitions to display partition overview table.")) - } - - return nil -} - -// displayClusterStatistics displays cluster-wide statistics in a readable format -func displayClusterStatistics(writer io.Writer, stats *gitalypb.ClusterStatistics, storageFilter string, colorOutput *colorOutput) error { - // Display overall cluster health at the top - partitionHealth := colorOutput.formatHealthStatus(int(stats.GetHealthyPartitions()), int(stats.GetTotalPartitions())) - replicaHealth := colorOutput.formatHealthStatus(int(stats.GetHealthyReplicas()), int(stats.GetTotalReplicas())) - - fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("=== Cluster Health Summary ===")) - fmt.Fprintf(writer, " Partitions: %s\n", partitionHealth) - fmt.Fprintf(writer, " Replicas: %s\n\n", replicaHealth) - - fmt.Fprintf(writer, "%s\n", colorOutput.formatHeader("=== Cluster Statistics ===")) - fmt.Fprintf(writer, " Total Partitions: %s\n", colorOutput.formatInfo(fmt.Sprintf("%d", stats.GetTotalPartitions()))) - fmt.Fprintf(writer, " Total Replicas: %s\n", colorOutput.formatInfo(fmt.Sprintf("%d", stats.GetTotalReplicas()))) - fmt.Fprintf(writer, " Healthy Partitions: %s\n", colorOutput.formatInfo(fmt.Sprintf("%d", stats.GetHealthyPartitions()))) - fmt.Fprintf(writer, " Healthy Replicas: %s\n", colorOutput.formatInfo(fmt.Sprintf("%d", stats.GetHealthyReplicas()))) - fmt.Fprintf(writer, "\n") - - if len(stats.GetStorageStats()) > 0 { - fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("=== Per-Storage Statistics ===")) - - // Filter storage names if a storage filter is specified - var storageNames []string - if storageFilter != "" { - // Only show the filtered storage if it exists - if _, exists := stats.GetStorageStats()[storageFilter]; exists { - storageNames = append(storageNames, storageFilter) - } - } else { - // Show all storages if no filter is specified - for storageName := range stats.GetStorageStats() { - storageNames = append(storageNames, storageName) - } - sort.Strings(storageNames) - } - - // Create table writer for storage statistics - tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) - - // Table headers - fmt.Fprintf(tw, "STORAGE\tLEADER COUNT\tREPLICA COUNT\n") - fmt.Fprintf(tw, "-------\t------------\t-------------\n") - - for _, storageName := range storageNames { - storageStat := stats.GetStorageStats()[storageName] - fmt.Fprintf(tw, "%s\t%d\t%d\n", - storageName, - storageStat.GetLeaderCount(), - storageStat.GetReplicaCount()) - } - - // Flush the table and add spacing - if err := tw.Flush(); err != nil { - return fmt.Errorf("failed to flush storage statistics table: %w", err) - } - fmt.Fprintf(writer, "\n") - } - return nil -} - -// displayPartitionTable displays partitions in a tabular format -func displayPartitionTable(writer io.Writer, partitions []*gitalypb.GetPartitionsResponse, storageFilter string, colorOutput *colorOutput) error { - fmt.Fprintf(writer, "%s\n\n", colorOutput.formatHeader("=== Partition Overview ===")) - - // Create table writer - tw := tabwriter.NewWriter(writer, 0, 0, 2, ' ', 0) - - // Table headers - fmt.Fprintf(tw, "PARTITION KEY\tLEADER\tREPLICAS\tHEALTH\tLAST INDEX\tMATCH INDEX\tREPOSITORIES\n") - fmt.Fprintf(tw, "-------------\t------\t--------\t------\t----------\t-----------\t------------\n") - - for _, partition := range partitions { - // Find leader and collect replica info - leader := "None" - healthyReplicas := 0 - totalReplicas := len(partition.GetReplicas()) - var replicaStorages []string - var filteredReplicas []string - var leaderLastIndex, leaderMatchIndex uint64 - - for _, replica := range partition.GetReplicas() { - storageName := replica.GetReplicaId().GetStorageName() - if replica.GetIsLeader() { - leader = colorOutput.formatInfo(storageName) - leaderLastIndex = replica.GetLastIndex() - leaderMatchIndex = replica.GetMatchIndex() - } - if replica.GetIsHealthy() { - healthyReplicas++ - } - replicaStorages = append(replicaStorages, storageName) - - // Collect replicas matching storage filter - if storageFilter != "" && storageName == storageFilter { - filteredReplicas = append(filteredReplicas, storageName) - } - } - - // Format replica list showing storage names - replicasStr := strings.Join(replicaStorages, ", ") - if storageFilter != "" && len(filteredReplicas) > 0 { - // Show all replicas but highlight the filtered ones - replicasStr = fmt.Sprintf("%s (filtered: %s)", replicasStr, strings.Join(filteredReplicas, ", ")) - } - - // Format health status with color - var healthStr string - if healthyReplicas == totalReplicas && totalReplicas > 0 { - healthStr = colorOutput.formatHealthy(fmt.Sprintf("%d/%d", healthyReplicas, totalReplicas)) - } else if healthyReplicas == 0 { - healthStr = colorOutput.formatUnhealthy(fmt.Sprintf("%d/%d", healthyReplicas, totalReplicas)) - } else { - healthStr = colorOutput.formatWarning(fmt.Sprintf("%d/%d", healthyReplicas, totalReplicas)) - } - - // Format last index and match index - lastIndexStr := "N/A" - matchIndexStr := "N/A" - if leader != "None" { - lastIndexStr = fmt.Sprintf("%d", leaderLastIndex) - matchIndexStr = fmt.Sprintf("%d", leaderMatchIndex) - } - - // Format repository count - repoCount := len(partition.GetRelativePaths()) - repoStr := fmt.Sprintf("%d repos", repoCount) - - // Display full partition key - partitionKeyDisplay := partition.GetPartitionKey().GetValue() - - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - partitionKeyDisplay, leader, replicasStr, healthStr, lastIndexStr, matchIndexStr, repoStr) - } - - // Flush the partition table - if err := tw.Flush(); err != nil { - return fmt.Errorf("failed to flush partition overview table: %w", err) } - return nil + return clusterInfoResp, partitionResponses, nil } // colorOutput holds color configuration for terminal output diff --git a/internal/cli/gitaly/subcmd_cluster_info_test.go b/internal/cli/gitaly/subcmd_cluster_info_test.go index ef7bfab10fe17f849107bc96ed4be41512875b98..be97f0ab6015210d6f4993bb845d12055bb1deb1 100644 --- a/internal/cli/gitaly/subcmd_cluster_info_test.go +++ b/internal/cli/gitaly/subcmd_cluster_info_test.go @@ -2,7 +2,6 @@ package gitaly import ( "bytes" - "fmt" "strings" "testing" @@ -107,16 +106,6 @@ storage-3 0 2 Use --list-partitions to display partition overview table. `, }, - { - name: "basic cluster info without partitions (robust check)", - setupServer: func(t *testing.T) (string, func()) { - return setupRaftServerForPartition(t, setupTestData) - }, - args: []string{}, - expectError: false, - // This test uses content validation instead of exact string matching - expectedOutput: "ROBUST_CHECK", - }, { name: "cluster info with show partitions flag", setupServer: func(t *testing.T) (string, func()) { @@ -185,6 +174,386 @@ PARTITION KEY LEADER REP ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98 storage-2 storage-1, storage-2, storage-3 (filtered: storage-1) 3/3 150 150 1 repos `, }, + { + name: "cluster info with JSON format (basic)", + setupServer: func(t *testing.T) (string, func()) { + return setupRaftServerForPartition(t, setupTestData) + }, + args: []string{"--format", "json"}, + expectError: false, + expectedOutput: `{ + "clusterInfo": { + "clusterId": "test-cluster", + "statistics": { + "healthyPartitions": 2, + "healthyReplicas": 6, + "storageStats": { + "storage-1": { + "leaderCount": 1, + "replicaCount": 2 + }, + "storage-2": { + "leaderCount": 1, + "replicaCount": 2 + }, + "storage-3": { + "replicaCount": 2 + } + }, + "totalPartitions": 2, + "totalReplicas": 6 + } + } +} +`, + }, + { + name: "cluster info with JSON format and list-partitions", + setupServer: func(t *testing.T) (string, func()) { + return setupRaftServerForPartition(t, setupTestData) + }, + args: []string{"--format", "json", "--list-partitions"}, + expectError: false, + expectedOutput: `{ + "clusterInfo": { + "clusterId": "test-cluster", + "statistics": { + "healthyPartitions": 2, + "healthyReplicas": 6, + "storageStats": { + "storage-1": { + "leaderCount": 1, + "replicaCount": 2 + }, + "storage-2": { + "leaderCount": 1, + "replicaCount": 2 + }, + "storage-3": { + "replicaCount": 2 + } + }, + "totalPartitions": 2, + "totalReplicas": 6 + } + }, + "partitions": [ + { + "clusterId": "test-cluster", + "index": "100", + "leaderId": "1", + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "relativePath": "@hashed/ab/cd/repo1.git", + "relativePaths": [ + "@hashed/ab/cd/repo1.git" + ], + "replicas": [ + { + "isHealthy": true, + "isLeader": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "1", + "metadata": { + "address": "gitaly-1.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-1", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "leader" + }, + { + "isHealthy": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "2", + "metadata": { + "address": "gitaly-2.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-2", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + }, + { + "isHealthy": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "3", + "metadata": { + "address": "gitaly-3.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-3", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + } + ], + "term": "5" + }, + { + "clusterId": "test-cluster", + "index": "150", + "leaderId": "5", + "partitionKey": { + "value": "ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98" + }, + "relativePath": "@hashed/ef/gh/repo2.git", + "relativePaths": [ + "@hashed/ef/gh/repo2.git" + ], + "replicas": [ + { + "isHealthy": true, + "lastIndex": "150", + "matchIndex": "150", + "replicaId": { + "memberId": "4", + "metadata": { + "address": "gitaly-1.example.com:8075" + }, + "partitionKey": { + "value": "ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98" + }, + "storageName": "storage-1", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + }, + { + "isHealthy": true, + "isLeader": true, + "lastIndex": "150", + "matchIndex": "150", + "replicaId": { + "memberId": "5", + "metadata": { + "address": "gitaly-2.example.com:8075" + }, + "partitionKey": { + "value": "ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98" + }, + "storageName": "storage-2", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "leader" + }, + { + "isHealthy": true, + "lastIndex": "150", + "matchIndex": "150", + "replicaId": { + "memberId": "6", + "metadata": { + "address": "gitaly-3.example.com:8075" + }, + "partitionKey": { + "value": "ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98" + }, + "storageName": "storage-3", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + } + ], + "term": "6" + } + ] +} +`, + }, + { + name: "cluster info with JSON format and storage filter", + setupServer: func(t *testing.T) (string, func()) { + return setupRaftServerForPartition(t, setupTestData) + }, + args: []string{"--format", "json", "--storage", storageOne}, + expectError: false, + expectedOutput: `{ + "clusterInfo": { + "clusterId": "test-cluster", + "statistics": { + "healthyPartitions": 2, + "healthyReplicas": 6, + "storageStats": { + "storage-1": { + "leaderCount": 1, + "replicaCount": 2 + }, + "storage-2": { + "leaderCount": 1, + "replicaCount": 2 + }, + "storage-3": { + "replicaCount": 2 + } + }, + "totalPartitions": 2, + "totalReplicas": 6 + } + }, + "partitions": [ + { + "clusterId": "test-cluster", + "index": "100", + "leaderId": "1", + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "relativePath": "@hashed/ab/cd/repo1.git", + "relativePaths": [ + "@hashed/ab/cd/repo1.git" + ], + "replicas": [ + { + "isHealthy": true, + "isLeader": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "1", + "metadata": { + "address": "gitaly-1.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-1", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "leader" + }, + { + "isHealthy": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "2", + "metadata": { + "address": "gitaly-2.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-2", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + }, + { + "isHealthy": true, + "lastIndex": "100", + "matchIndex": "100", + "replicaId": { + "memberId": "3", + "metadata": { + "address": "gitaly-3.example.com:8075" + }, + "partitionKey": { + "value": "1ae75994b13cfe1d19983e0d7eeac7b4a7077bd9c4a26e3421c1acd3d683a4ad" + }, + "storageName": "storage-3", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + } + ], + "term": "5" + }, + { + "clusterId": "test-cluster", + "index": "150", + "leaderId": "5", + "partitionKey": { + "value": "ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98" + }, + "relativePath": "@hashed/ef/gh/repo2.git", + "relativePaths": [ + "@hashed/ef/gh/repo2.git" + ], + "replicas": [ + { + "isHealthy": true, + "lastIndex": "150", + "matchIndex": "150", + "replicaId": { + "memberId": "4", + "metadata": { + "address": "gitaly-1.example.com:8075" + }, + "partitionKey": { + "value": "ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98" + }, + "storageName": "storage-1", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + }, + { + "isHealthy": true, + "isLeader": true, + "lastIndex": "150", + "matchIndex": "150", + "replicaId": { + "memberId": "5", + "metadata": { + "address": "gitaly-2.example.com:8075" + }, + "partitionKey": { + "value": "ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98" + }, + "storageName": "storage-2", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "leader" + }, + { + "isHealthy": true, + "lastIndex": "150", + "matchIndex": "150", + "replicaId": { + "memberId": "6", + "metadata": { + "address": "gitaly-3.example.com:8075" + }, + "partitionKey": { + "value": "ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98" + }, + "storageName": "storage-3", + "type": "REPLICA_TYPE_VOTER" + }, + "state": "follower" + } + ], + "term": "6" + } + ] +} +`, + }, + { + name: "cluster info with invalid format", + setupServer: func(t *testing.T) (string, func()) { + return setupRaftServerForPartition(t, setupTestData) + }, + args: []string{"--format", "yaml"}, + expectError: true, + expectedOutput: "invalid format \"yaml\": must be 'text' or 'json'", + }, } for _, tc := range tests { @@ -211,45 +580,12 @@ ae3928eb528786e728edb0583f06ec25d4d0f41f3ad6105a8c2777790d8cfc98 storage-2 sto require.Contains(t, err.Error(), tc.expectedOutput) } else { require.NoError(t, err, "Command should execute successfully") - - // Check if this is a robust test case - if tc.expectedOutput == "ROBUST_CHECK" { - // Use content-based validation instead of exact string matching - assertClusterStatistics(t, actualOutput, 2, 6) - assertOutputContains(t, actualOutput, []string{ - "=== Gitaly Cluster Information ===", - "=== Per-Storage Statistics ===", - "storage-1", - "storage-2", - "storage-3", - "Use --list-partitions to display partition overview table.", - }) - } else { - require.Equal(t, tc.expectedOutput, actualOutput, "Output should match exactly") - } + require.Equal(t, tc.expectedOutput, actualOutput, "Output should match exactly") } }) } } -// assertOutputContains checks that output contains all expected content parts without enforcing exact formatting -func assertOutputContains(t *testing.T, output string, expectedParts []string) { - t.Helper() - for _, part := range expectedParts { - require.Contains(t, output, part, "Output should contain: %q", part) - } -} - -// assertClusterStatistics validates cluster statistics in output without exact formatting -func assertClusterStatistics(t *testing.T, output string, expectedPartitions, expectedReplicas int) { - t.Helper() - require.Contains(t, output, "=== Cluster Statistics ===") - require.Contains(t, output, fmt.Sprintf("Total Partitions: %d", expectedPartitions)) - require.Contains(t, output, fmt.Sprintf("Total Replicas: %d", expectedReplicas)) - require.Contains(t, output, fmt.Sprintf("Healthy Partitions: %d", expectedPartitions)) - require.Contains(t, output, fmt.Sprintf("Healthy Replicas: %d", expectedReplicas)) -} - // setupTestData populates the Raft cluster with test partition data func setupTestData(t *testing.T, cfg any, node *raftmgr.Node) { // Set up mock routing table entries diff --git a/internal/testhelper/testserver/gitaly.go b/internal/testhelper/testserver/gitaly.go index b995c1c68c938405160c9b747abe257559d3972a..7193a01ae45f78f826778076715063ecd33b8c03 100644 --- a/internal/testhelper/testserver/gitaly.go +++ b/internal/testhelper/testserver/gitaly.go @@ -380,7 +380,11 @@ func (gsd *gitalyServerDeps) createDependencies(tb testing.TB, ctx context.Conte var raftFactory raftmgr.RaftReplicaFactory var raftNode *raftmgr.Node if testhelper.IsRaftEnabled() && !testhelper.IsPraefectEnabled() { - cfg.Raft = config.DefaultRaftConfig(uuid.New().String()) + clusterID := cfg.Raft.ClusterID + if clusterID == "" { + clusterID = uuid.New().String() + } + cfg.Raft = config.DefaultRaftConfig(clusterID) // Speed up initial election overhead in the test setup cfg.Raft.ElectionTicks = 5 cfg.Raft.RTTMilliseconds = 100