Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ require (
github.com/confluentinc/ccloud-sdk-go-v2/cdx v0.0.5
github.com/confluentinc/ccloud-sdk-go-v2/certificate-authority v0.0.2
github.com/confluentinc/ccloud-sdk-go-v2/cli v0.3.0
github.com/confluentinc/ccloud-sdk-go-v2/cmk v0.25.0
github.com/confluentinc/ccloud-sdk-go-v2/cmk v0.26.0
github.com/confluentinc/ccloud-sdk-go-v2/connect v0.7.0
github.com/confluentinc/ccloud-sdk-go-v2/connect-custom-plugin v0.0.9
github.com/confluentinc/ccloud-sdk-go-v2/flink v0.11.0
Expand Down Expand Up @@ -111,7 +111,7 @@ require (
go.uber.org/mock v0.4.0
golang.org/x/crypto v0.46.0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/oauth2 v0.34.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.38.0
golang.org/x/text v0.32.0
google.golang.org/protobuf v1.36.10
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ github.com/confluentinc/ccloud-sdk-go-v2/certificate-authority v0.0.2 h1:stsiO1J
github.com/confluentinc/ccloud-sdk-go-v2/certificate-authority v0.0.2/go.mod h1:OU1RGuP2y5l54jX5rA++QBAKeRvSa7GmkfNgJvB9J6M=
github.com/confluentinc/ccloud-sdk-go-v2/cli v0.3.0 h1:OOFNqtZN3Spuzz4TX6K6JfDM7zNDIE6BE1TtK78jFHQ=
github.com/confluentinc/ccloud-sdk-go-v2/cli v0.3.0/go.mod h1:Mv0WTsBXUfKjmF+r2t2Dv/xJzZf17shhf5J1cttU2Qo=
github.com/confluentinc/ccloud-sdk-go-v2/cmk v0.25.0 h1:EdZzQZ4SI5q+f0DQPjH3lWpygz1wYz7IE0K62Mv06bY=
github.com/confluentinc/ccloud-sdk-go-v2/cmk v0.25.0/go.mod h1:FSSO9mkNPJKMa7Ky66IlXEG7o5H6cpyuKKClvRf9y+0=
github.com/confluentinc/ccloud-sdk-go-v2/cmk v0.26.0 h1:GVGsu/DtcHaQH3yuvovpPbjNrcwez0N7+e4rbCQ/QKU=
github.com/confluentinc/ccloud-sdk-go-v2/cmk v0.26.0/go.mod h1:wtfggR9vJMVda+fMBamv3/nNxSDB+unboplVzzcQlGY=
github.com/confluentinc/ccloud-sdk-go-v2/connect v0.7.0 h1:ISrVOX9qJ2Sxiu/fGBqqHeaA0SRJQujc8yP7qAZRL3Y=
github.com/confluentinc/ccloud-sdk-go-v2/connect v0.7.0/go.mod h1:zHG/3DzsnoHC81B1AY9K/8bMX3mxbIp5/nHHdypa//w=
github.com/confluentinc/ccloud-sdk-go-v2/connect-custom-plugin v0.0.9 h1:o1zKZlKbnN9uv+Y8TxwesBRryUl3lEU6lnfndEJigxQ=
Expand Down Expand Up @@ -1024,8 +1024,8 @@ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210323180902-22b0adad7558/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down
9 changes: 9 additions & 0 deletions internal/kafka/command_cluster_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
cmd.Flags().Int("cku", 0, `Number of Confluent Kafka Units (non-negative). Required for Kafka clusters of type "dedicated".`)
cmd.Flags().Int("max-ecku", 0, `Maximum number of Elastic Confluent Kafka Units (eCKUs) that Kafka clusters should auto-scale to. `+
`Kafka clusters with "HIGH" availability must have at least two eCKUs.`)
cmd.Flags().Bool("deletion-protection", false, "Enable deletion protection for the Kafka cluster.")

Check failure on line 68 in internal/kafka/command_cluster_create.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Define a constant instead of duplicating this literal "deletion-protection" 3 times.

[S1192] String literals should not be duplicated See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3318&issues=4f4257c0-f30f-4942-9db0-8d3555005211&open=4f4257c0-f30f-4942-9db0-8d3555005211
pcmd.AddByokKeyFlag(cmd, c.AuthenticatedCLICommand)
pcmd.AddNetworkFlag(cmd, c.AuthenticatedCLICommand)
pcmd.AddContextFlag(cmd, c.CLICommand)
Expand All @@ -77,7 +78,7 @@
return cmd
}

func (c *clusterCommand) create(cmd *cobra.Command, args []string) error {

Check failure on line 81 in internal/kafka/command_cluster_create.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Refactor this method to reduce its Cognitive Complexity from 40 to the 15 allowed.

[S3776] Cognitive Complexity of functions should not be too high See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3318&issues=82a07883-5b5c-4b38-990b-5fa5e5dcd9e1&open=82a07883-5b5c-4b38-990b-5fa5e5dcd9e1
cloud, err := cmd.Flags().GetString("cloud")
if err != nil {
return err
Expand Down Expand Up @@ -171,6 +172,14 @@
setClusterConfigCku(&createCluster, int32(cku))
}

if cmd.Flags().Changed("deletion-protection") {
deletionProtection, err := cmd.Flags().GetBool("deletion-protection")
if err != nil {
return err
}
createCluster.Spec.SetDeletionProtection(deletionProtection)
}

if cmd.Flags().Changed("network") {
network, err := cmd.Flags().GetString("network")
if err != nil {
Expand Down
71 changes: 69 additions & 2 deletions internal/kafka/command_cluster_delete.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package kafka

import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"

"github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"

Expand All @@ -10,6 +16,11 @@
"github.com/confluentinc/cli/v4/pkg/resource"
)

const (
errorCodeDeletionProtectionEnabled = "deletion_protection_enabled"
clusterDeletionProtectionDetail = "Cluster deletion is blocked by deletion protection."
)

func (c *clusterCommand) newDeleteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <id-1> [id-2] ... [id-n]",
Expand Down Expand Up @@ -43,8 +54,12 @@
return errors.NewErrorWithSuggestions(err.Error(), PluralClusterEnvironmentSuggestions)
}

deletionProtectionDetail := ""
deleteFunc := func(id string) error {
if httpResp, err := c.V2Client.DeleteKafkaCluster(id, environmentId); err != nil {
if detail, ok := parseDeletionProtectionErrDetail(httpResp); ok {
deletionProtectionDetail = detail
}
return errors.CatchKafkaNotFoundError(err, id, httpResp)
}
return nil
Expand All @@ -54,16 +69,68 @@

errs := multierror.Append(err, c.removeKafkaClusterConfigs(deletedIds))
if errs.ErrorOrNil() != nil {
if suggestion := deletionProtectionErrorToSuggestion(deletionProtectionDetail); suggestion != "" {
return errors.NewErrorWithSuggestions(errs.Error(), suggestion)
}
if len(args)-len(deletedIds) > 1 {
return errors.NewErrorWithSuggestions(err.Error(), "Ensure the clusters are not associated with any active Connect clusters.")
return errors.NewErrorWithSuggestions(errs.Error(),
"Ensure the clusters are not associated with any active Connect clusters.")
} else {
return errors.NewErrorWithSuggestions(err.Error(), "Ensure the cluster is not associated with any active Connect clusters.")
return errors.NewErrorWithSuggestions(errs.Error(),
"Ensure the cluster is not associated with any active Connect clusters.")
Comment thread
hydradon marked this conversation as resolved.
}
}

return nil
}

type apiError struct {
Code string `json:"code"`
Detail string `json:"detail"`
}

type apiErrorResponse struct {
Errors []apiError `json:"errors"`
}

// parseDeletionProtectionErrDetail checks if the HTTP response indicates a deletion protection error
// Returns the error detail and true if the error is a deletion protection error, or empty string and false otherwise.
func parseDeletionProtectionErrDetail(r *http.Response) (string, bool) {
if r == nil || r.StatusCode != http.StatusConflict || r.Body == nil {
return "", false
}

body, err := io.ReadAll(r.Body)
if err != nil {
return "", false
}
// Restore the body so downstream handlers can read it
r.Body = io.NopCloser(bytes.NewBuffer(body))

var res apiErrorResponse
if err := json.Unmarshal(body, &res); err != nil {

Check warning on line 111 in internal/kafka/command_cluster_delete.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Remove this unnecessary variable declaration and use the expression directly in the condition.

[S8193] Variables in if short statements should be used beyond just the condition See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3318&issues=d2bac030-2d15-4d48-a7d3-c8cb29737c98&open=d2bac030-2d15-4d48-a7d3-c8cb29737c98
return "", false
}

for _, apiErr := range res.Errors {
if apiErr.Code == errorCodeDeletionProtectionEnabled {
return apiErr.Detail, true
}
}

return "", false
}

// deletionProtectionErrorToSuggestion maps a deletion protection error detail to a user-facing suggestion.
func deletionProtectionErrorToSuggestion(errorMsg string) string {
switch {
case strings.EqualFold(errorMsg, clusterDeletionProtectionDetail):
return `Disable deletion_protection before deleting the cluster.`
default:
return ""
}
}

func (c *clusterCommand) removeKafkaClusterConfigs(deletedIds []string) error {
for _, id := range deletedIds {
c.Context.KafkaClusterContext.RemoveKafkaCluster(id)
Expand Down
146 changes: 146 additions & 0 deletions internal/kafka/command_cluster_delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package kafka

import (
"io"
"net/http"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestParseDeletionProtectionErrDetail(t *testing.T) {
tests := []struct {
name string
response *http.Response
expectedDetail string
expectedOk bool
}{
{
name: "nil response",
response: nil,
expectedOk: false,
},
{
name: "non-conflict status code",
response: &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(`{"errors": [{"code": "deletion_protection_enabled", "detail": "test"}]}`)),
},
expectedOk: false,
},
{
name: "conflict with deletion protection error code",
response: &http.Response{
StatusCode: http.StatusConflict,
Body: io.NopCloser(strings.NewReader(`{"errors": [{"code": "deletion_protection_enabled", "detail": "Cluster deletion is blocked by deletion protection."}]}`)),
},
expectedDetail: "Cluster deletion is blocked by deletion protection.",

Check failure on line 38 in internal/kafka/command_cluster_delete_test.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Define a constant instead of duplicating this literal "Cluster deletion is blocked by deletion protection." 4 times.

[S1192] String literals should not be duplicated See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3318&issues=0e00f141-09e8-498c-b972-15e1d36b403b&open=0e00f141-09e8-498c-b972-15e1d36b403b
expectedOk: true,
},
{
name: "conflict with different error code",
response: &http.Response{
StatusCode: http.StatusConflict,
Body: io.NopCloser(strings.NewReader(`{"errors": [{"code": "some_other_error", "detail": "some detail"}]}`)),
},
expectedOk: false,
},
{
name: "conflict with empty errors array",
response: &http.Response{
StatusCode: http.StatusConflict,
Body: io.NopCloser(strings.NewReader(`{"errors": []}`)),
},
expectedOk: false,
},
{
name: "conflict with invalid JSON body",
response: &http.Response{
StatusCode: http.StatusConflict,
Body: io.NopCloser(strings.NewReader(`not json`)),
},
expectedOk: false,
},
{
name: "conflict with nil body",
response: &http.Response{
StatusCode: http.StatusConflict,
Body: nil,
},
expectedOk: false,
},
{
name: "deletion protection error is not first in errors array",
response: &http.Response{
StatusCode: http.StatusConflict,
Body: io.NopCloser(strings.NewReader(`{"errors": [` +
`{"code": "some_other_error", "detail": "other error"},` +
`{"code": "deletion_protection_enabled", "detail": "Cluster deletion is blocked by deletion protection."}` +
`]}`)),
},
expectedDetail: "Cluster deletion is blocked by deletion protection.",
expectedOk: true,
},
{
name: "body is restored after reading",
response: &http.Response{
StatusCode: http.StatusConflict,
Body: io.NopCloser(strings.NewReader(`{"errors": [{"code": "deletion_protection_enabled", "detail": "Cluster deletion is blocked by deletion protection."}]}`)),
},
expectedDetail: "Cluster deletion is blocked by deletion protection.",
expectedOk: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
detail, ok := parseDeletionProtectionErrDetail(tt.response)
require.Equal(t, tt.expectedOk, ok)
require.Equal(t, tt.expectedDetail, detail)

// Verify body is restored for downstream handlers
if tt.response != nil && tt.response.Body != nil && ok {
body, err := io.ReadAll(tt.response.Body)
require.NoError(t, err)
require.NotEmpty(t, body)
}
})
}
}

func TestDeletionProtectionErrorToSuggestion(t *testing.T) {
tests := []struct {
name string
errorMsg string
expectedSuggestion string
}{
{
name: "cluster deletion protection",
errorMsg: "Cluster deletion is blocked by deletion protection.",
expectedSuggestion: `Disable deletion_protection before deleting the cluster.`,
},
{
name: "cluster deletion protection case insensitive",
errorMsg: "cluster deletion is blocked by deletion protection.",
expectedSuggestion: `Disable deletion_protection before deleting the cluster.`,
},
{
name: "unknown deletion protection error",
errorMsg: "Some other deletion protection error.",
expectedSuggestion: "",
},
{
name: "empty string",
errorMsg: "",
expectedSuggestion: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
suggestion := deletionProtectionErrorToSuggestion(tt.errorMsg)
require.Equal(t, tt.expectedSuggestion, suggestion)
})
}
}
4 changes: 3 additions & 1 deletion internal/kafka/command_cluster_describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"github.com/confluentinc/cli/v4/pkg/resource"
)

var basicDescribeFields = []string{"IsCurrent", "Id", "Name", "Type", "IngressLimit", "EgressLimit", "Storage", "Cloud", "Availability", "Region", "Network", "Status", "Endpoint", "RestEndpoint"}
var basicDescribeFields = []string{"IsCurrent", "Id", "Name", "Type", "IngressLimit", "EgressLimit", "Storage", "Cloud", "Availability", "Region", "Network", "Status", "DeletionProtection", "Endpoint", "RestEndpoint"}

type describeStruct struct {
IsCurrent bool `human:"Current" serialized:"is_current"`
Expand All @@ -35,6 +35,7 @@ type describeStruct struct {
Availability string `human:"Availability" serialized:"availability"`
Network string `human:"Network,omitempty" serialized:"network,omitempty"`
Status string `human:"Status" serialized:"status"`
DeletionProtection bool `human:"Deletion Protection" serialized:"deletion_protection"`
Endpoint string `human:"Endpoint" serialized:"endpoint"`
ByokKeyId string `human:"BYOK Key ID" serialized:"byok_key_id"`
EncryptionKeyId string `human:"Encryption Key ID" serialized:"encryption_key_id"`
Expand Down Expand Up @@ -146,6 +147,7 @@ func convertClusterToDescribeStruct(cluster *cmkv2.CmkV2Cluster, usageLimits *ka
Availability: ccloudv2.ToLower(cluster.Spec.GetAvailability()),
Network: cluster.Spec.Network.GetId(),
Status: getCmkClusterStatus(cluster),
DeletionProtection: cluster.Spec.GetDeletionProtection(),
Endpoint: cluster.Spec.GetKafkaBootstrapEndpoint(),
ByokKeyId: getCmkByokId(cluster),
EncryptionKeyId: getCmkEncryptionKey(cluster),
Expand Down
2 changes: 1 addition & 1 deletion internal/kafka/command_cluster_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,6 @@ func (c *clusterCommand) list(cmd *cobra.Command, _ []string) error {
for _, cluster := range clusters {
list.Add(convertClusterToDescribeStruct(&cluster, nil, c.Context))
}
list.Filter([]string{"IsCurrent", "Id", "Name", "Type", "Cloud", "Region", "Availability", "Network", "Status", "Endpoint"})
list.Filter([]string{"IsCurrent", "Id", "Name", "Type", "Cloud", "Region", "Availability", "Network", "Status", "DeletionProtection", "Endpoint"})
return list.Print()
}
11 changes: 10 additions & 1 deletion internal/kafka/command_cluster_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,20 @@
cmd.Flags().String("name", "", "Name of the Kafka cluster.")
cmd.Flags().Uint32("cku", 0, `Number of Confluent Kafka Units. For Kafka clusters of type "dedicated" only. When shrinking a cluster, you must reduce capacity one CKU at a time.`)
cmd.Flags().String("type", "", `Type of the Kafka cluster. Only supports upgrading from "Basic" to "Standard".`)
cmd.Flags().Int("max-ecku", 0, `Maximum number of Elastic Confluent Kafka Units (eCKUs) that Kafka clusters should auto-scale to. `+

Check failure on line 45 in internal/kafka/command_cluster_update.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Define a constant instead of duplicating this literal "max-ecku" 4 times.

[S1192] String literals should not be duplicated See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3318&issues=63493b45-51f3-4bb9-ab6c-11c39eca253d&open=63493b45-51f3-4bb9-ab6c-11c39eca253d
`Kafka clusters with "HIGH" availability must have at least two eCKUs.`)
cmd.Flags().Bool("deletion-protection", false, "Enable deletion protection for the Kafka cluster. Use \"--deletion-protection=false\" to disable.")

Check failure on line 47 in internal/kafka/command_cluster_update.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Define a constant instead of duplicating this literal "deletion-protection" 4 times.

[S1192] String literals should not be duplicated See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3318&issues=03dae06d-ccf5-4e88-93ce-45b81a348fbc&open=03dae06d-ccf5-4e88-93ce-45b81a348fbc
pcmd.AddContextFlag(cmd, c.CLICommand)
pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand)
pcmd.AddEndpointFlag(cmd, c.AuthenticatedCLICommand)
pcmd.AddOutputFlag(cmd)

cmd.MarkFlagsOneRequired("name", "cku", "type", "max-ecku")
cmd.MarkFlagsOneRequired("name", "cku", "type", "max-ecku", "deletion-protection")

return cmd
}

func (c *clusterCommand) update(cmd *cobra.Command, args []string) error {

Check failure on line 58 in internal/kafka/command_cluster_update.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Refactor this method to reduce its Cognitive Complexity from 45 to the 15 allowed.

[S3776] Cognitive Complexity of functions should not be too high See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3318&issues=e06e55f6-7381-488a-9dd6-b50115b89221&open=e06e55f6-7381-488a-9dd6-b50115b89221
environmentId, err := c.Context.EnvironmentId()
if err != nil {
return err
Expand Down Expand Up @@ -85,6 +86,14 @@
update.Spec.SetDisplayName(name)
}

if cmd.Flags().Changed("deletion-protection") {
deletionProtection, err := cmd.Flags().GetBool("deletion-protection")
if err != nil {
return err
}
update.Spec.SetDeletionProtection(deletionProtection)
}

if cmd.Flags().Changed("cku") {
cku, err := cmd.Flags().GetUint32("cku")
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/output/kafka/1.golden
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Flags:
--type string Specify the type of the Kafka cluster as "basic", "standard", "enterprise", "freight", or "dedicated". (default "basic")
--cku int Number of Confluent Kafka Units (non-negative). Required for Kafka clusters of type "dedicated".
--max-ecku int Maximum number of Elastic Confluent Kafka Units (eCKUs) that Kafka clusters should auto-scale to. Kafka clusters with "HIGH" availability must have at least two eCKUs.
--deletion-protection Enable deletion protection for the Kafka cluster.
--byok string Confluent Cloud Key ID of a registered encryption key (use "confluent byok create" to register a key).
--network string Network ID.
--context string CLI context name.
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/output/kafka/17.golden
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
| Region | us-west-2 |
| Availability | single-zone |
| Status | UP |
| Deletion Protection | false |
| Endpoint | SASL_SSL://kafka-endpoint |
| REST Endpoint | http://127.0.0.1:1025 |
| Topic Count | 2 |
Expand Down
Loading