Skip to content

Commit 5b8b86a

Browse files
wyuzhengclaude
andcommitted
[ORC-10127] Add switchover CLI commands (pair, endpoint)
Adds `confluent switchover pair` and `confluent switchover endpoint` commands (create/list/describe/update/delete + `pair failover` and `endpoint activate`), the ccloudv2 SDK wrapper, output structs, resource constants, command + client registration, and test-server handlers. Hand-written: cli-terraform-generator cannot generate these resources because their required create fields (members, endpoints) are arrays-of-objects, which its CLI parser does not represent as flags (documented limitation). Builds against switchover/v1 SDK; go build of internal/, internal/switchover, pkg/ccloudv2 passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 13d94db commit 5b8b86a

25 files changed

Lines changed: 1345 additions & 0 deletions

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/charmbracelet/lipgloss v0.11.0
1616
github.com/client9/gospell v0.0.0-20160306015952-90dfc71015df
1717
github.com/confluentinc/ccloud-sdk-go-v1-public v0.0.0-20250521223017-0e8f6f971b52
18+
github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover v0.0.0-20260612194002-842615b031fd
1819
github.com/confluentinc/ccloud-sdk-go-v2/ai v0.1.0
1920
github.com/confluentinc/ccloud-sdk-go-v2/apikeys v0.4.0
2021
github.com/confluentinc/ccloud-sdk-go-v2/billing v0.3.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
172172
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
173173
github.com/confluentinc/ccloud-sdk-go-v1-public v0.0.0-20250521223017-0e8f6f971b52 h1:19qEGhkbZa5fopKCe0VPIV+Sasby4Pv10z9ZaktwWso=
174174
github.com/confluentinc/ccloud-sdk-go-v1-public v0.0.0-20250521223017-0e8f6f971b52/go.mod h1:62EMf+5uFEt1BJ2q8WMrUoI9VUSxAbDnmZCGRt/MbA0=
175+
github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover v0.0.0-20260612194002-842615b031fd h1:+cvg0ZJFpRo9NhF0hYCov6zCJ5BXPbrjvcXrHD7iVuE=
176+
github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover v0.0.0-20260612194002-842615b031fd/go.mod h1:75XYxEMix/kXHlKza4pP9P2in9w1tB3vM2TY3G4mHl4=
175177
github.com/confluentinc/ccloud-sdk-go-v2/ai v0.1.0 h1:zSF4OQUJXWH2JeAo9rsq13ibk+JFdzITGR8S7cFMpzw=
176178
github.com/confluentinc/ccloud-sdk-go-v2/ai v0.1.0/go.mod h1:DoxqzzF3JzvJr3fWkvCiOHFlE0GoYpozWxFZ1Ud9ntA=
177179
github.com/confluentinc/ccloud-sdk-go-v2/apikeys v0.4.0 h1:8fWyLwMuy8ec0MVF5Avd54UvbIxhDFhZzanHBVwgxdw=

internal/command.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444
"github.com/confluentinc/cli/v4/internal/secret"
4545
servicequota "github.com/confluentinc/cli/v4/internal/service-quota"
4646
streamshare "github.com/confluentinc/cli/v4/internal/stream-share"
47+
"github.com/confluentinc/cli/v4/internal/switchover"
4748
"github.com/confluentinc/cli/v4/internal/tableflow"
4849
unifiedstreammanager "github.com/confluentinc/cli/v4/internal/unified-stream-manager"
4950
"github.com/confluentinc/cli/v4/internal/update"
@@ -138,6 +139,7 @@ func NewConfluentCommand(cfg *config.Config) *cobra.Command {
138139
cmd.AddCommand(streamshare.New(prerunner))
139140
cmd.AddCommand(tableflow.New(prerunner))
140141
cmd.AddCommand(unifiedstreammanager.New(cfg, prerunner))
142+
cmd.AddCommand(switchover.New(prerunner))
141143
cmd.AddCommand(update.New(cfg, prerunner))
142144
cmd.AddCommand(version.New(prerunner, cfg.Version))
143145
// cli-tfgen:cli-commands — DO NOT REMOVE (verified by TestCliTfgenMarkers)

internal/switchover/command.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package switchover
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
pcmd "github.com/confluentinc/cli/v4/pkg/cmd"
7+
)
8+
9+
type command struct {
10+
*pcmd.AuthenticatedCLICommand
11+
}
12+
13+
// New returns the parent `confluent switchover` command, which groups the
14+
// Kafka Disaster Recovery resources: switchover pairs and switchover endpoints.
15+
func New(prerunner pcmd.PreRunner) *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "switchover",
18+
Short: "Manage Kafka Disaster Recovery switchover resources.",
19+
Long: "Manage Kafka Disaster Recovery switchover pairs and switchover endpoints in Confluent Cloud.",
20+
Annotations: map[string]string{pcmd.RunRequirement: pcmd.RequireCloudLogin},
21+
}
22+
23+
c := &command{AuthenticatedCLICommand: pcmd.NewAuthenticatedCLICommand(cmd, prerunner)}
24+
25+
cmd.AddCommand(c.newPairCommand())
26+
cmd.AddCommand(c.newEndpointCommand())
27+
28+
return cmd
29+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package switchover
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/spf13/cobra"
8+
9+
switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1"
10+
11+
"github.com/confluentinc/cli/v4/pkg/ccloudv2"
12+
"github.com/confluentinc/cli/v4/pkg/output"
13+
)
14+
15+
func (c *command) newEndpointCommand() *cobra.Command {
16+
cmd := &cobra.Command{
17+
Use: "endpoint",
18+
Short: "Manage switchover endpoints.",
19+
Long: "Manage endpoint-level Disaster Recovery switchover endpoints for Kafka.",
20+
Args: cobra.NoArgs,
21+
}
22+
23+
cmd.AddCommand(c.newEndpointActivateCommand())
24+
cmd.AddCommand(c.newEndpointCreateCommand())
25+
cmd.AddCommand(c.newEndpointDeleteCommand())
26+
cmd.AddCommand(c.newEndpointDescribeCommand())
27+
cmd.AddCommand(c.newEndpointListCommand())
28+
cmd.AddCommand(c.newEndpointUpdateCommand())
29+
30+
return cmd
31+
}
32+
33+
// parseEndpoint parses an `--endpoint` flag value into a SwitchoverV1EndpointConfig.
34+
// Format: comma-separated key=value pairs, for example:
35+
//
36+
// name=west-platt,resource-id=lkc-west,type=PRIVATE,cloud=AWS,region=us-west-2,gateway=n-ccn-west-1
37+
//
38+
// `name`, `resource-id`, and `type` are required; `cloud`, `region`, `gateway`,
39+
// and `access-point` are optional.
40+
func parseEndpoint(raw string) (switchoverv1.SwitchoverV1EndpointConfig, error) {
41+
fields := map[string]string{}
42+
for _, pair := range strings.Split(raw, ",") {
43+
if strings.TrimSpace(pair) == "" {
44+
continue
45+
}
46+
kv := strings.SplitN(pair, "=", 2)
47+
if len(kv) != 2 {
48+
return switchoverv1.SwitchoverV1EndpointConfig{}, fmt.Errorf(`invalid --endpoint value %q: each field must be "key=value"`, raw)
49+
}
50+
fields[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
51+
}
52+
53+
name := fields["name"]
54+
resourceId := fields["resource-id"]
55+
endpointType := strings.ToUpper(fields["type"])
56+
if name == "" || resourceId == "" || endpointType == "" {
57+
return switchoverv1.SwitchoverV1EndpointConfig{}, fmt.Errorf(`invalid --endpoint value %q: "name", "resource-id", and "type" are required`, raw)
58+
}
59+
60+
filter := switchoverv1.SwitchoverV1EndpointFilter{
61+
ResourceId: resourceId,
62+
Type: endpointType,
63+
}
64+
if v, ok := fields["cloud"]; ok {
65+
filter.SetCloud(strings.ToUpper(v))
66+
}
67+
if v, ok := fields["region"]; ok {
68+
filter.SetRegion(v)
69+
}
70+
if v, ok := fields["gateway"]; ok {
71+
filter.SetGateway(v)
72+
}
73+
if v, ok := fields["access-point"]; ok {
74+
filter.SetAccessPoint(v)
75+
}
76+
77+
return switchoverv1.SwitchoverV1EndpointConfig{Name: name, EndpointFilter: filter}, nil
78+
}
79+
80+
func (c *command) validEndpointArgs(cmd *cobra.Command, args []string) []string {
81+
if len(args) > 0 {
82+
return nil
83+
}
84+
if err := c.PersistentPreRunE(cmd, args); err != nil {
85+
return nil
86+
}
87+
environmentId, err := c.Context.EnvironmentId()
88+
if err != nil {
89+
return nil
90+
}
91+
return autocompleteEndpoints(c.V2Client, environmentId)
92+
}
93+
94+
func autocompleteEndpoints(client *ccloudv2.Client, environmentId string) []string {
95+
endpoints, err := client.ListSwitchoverEndpoints(environmentId)
96+
if err != nil {
97+
return nil
98+
}
99+
suggestions := make([]string, len(endpoints))
100+
for i, endpoint := range endpoints {
101+
suggestions[i] = fmt.Sprintf("%s\t%s", endpoint.GetId(), endpoint.Spec.GetDisplayName())
102+
}
103+
return suggestions
104+
}
105+
106+
func endpointNames(endpoint switchoverv1.SwitchoverV1SwitchoverEndpoint) []string {
107+
if endpoint.Spec == nil {
108+
return nil
109+
}
110+
names := make([]string, 0, len(endpoint.Spec.GetEndpoints()))
111+
for _, config := range endpoint.Spec.GetEndpoints() {
112+
names = append(names, config.GetName())
113+
}
114+
return names
115+
}
116+
117+
func printEndpointTable(cmd *cobra.Command, endpoint switchoverv1.SwitchoverV1SwitchoverEndpoint) error {
118+
if endpoint.Spec == nil {
119+
return fmt.Errorf("switchover endpoint response is missing its spec")
120+
}
121+
122+
out := &endpointOut{
123+
Id: endpoint.GetId(),
124+
Name: endpoint.Spec.GetDisplayName(),
125+
Environment: endpoint.Spec.Environment.GetId(),
126+
SwitchoverPair: endpoint.Spec.SwitchoverPair.GetId(),
127+
Endpoints: endpointNames(endpoint),
128+
Target: endpoint.Spec.GetTarget(),
129+
DrEndpoint: endpoint.Spec.GetDrEndpoint(),
130+
}
131+
if endpoint.Status != nil {
132+
out.Phase = endpoint.Status.GetPhase()
133+
}
134+
135+
table := output.NewTable(cmd)
136+
table.Add(out)
137+
return table.Print()
138+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package switchover
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
pcmd "github.com/confluentinc/cli/v4/pkg/cmd"
7+
"github.com/confluentinc/cli/v4/pkg/examples"
8+
)
9+
10+
func (c *command) newEndpointActivateCommand() *cobra.Command {
11+
cmd := &cobra.Command{
12+
Use: "activate <id>",
13+
Short: "Activate a switchover endpoint.",
14+
Long: "Activate a switchover endpoint, applying its desired routing target.",
15+
Args: cobra.ExactArgs(1),
16+
ValidArgsFunction: pcmd.NewValidArgsFunction(c.validEndpointArgs),
17+
RunE: c.endpointActivate,
18+
Example: examples.BuildExampleString(
19+
examples.Example{
20+
Text: `Activate switchover endpoint "se-123456".`,
21+
Code: "confluent switchover endpoint activate se-123456",
22+
},
23+
),
24+
}
25+
26+
pcmd.AddContextFlag(cmd, c.CLICommand)
27+
pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand)
28+
pcmd.AddOutputFlag(cmd)
29+
30+
return cmd
31+
}
32+
33+
func (c *command) endpointActivate(cmd *cobra.Command, args []string) error {
34+
endpoint, err := c.V2Client.ActivateSwitchoverEndpoint(args[0])
35+
if err != nil {
36+
return err
37+
}
38+
39+
return printEndpointTable(cmd, endpoint)
40+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package switchover
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1"
9+
10+
pcmd "github.com/confluentinc/cli/v4/pkg/cmd"
11+
"github.com/confluentinc/cli/v4/pkg/examples"
12+
)
13+
14+
func (c *command) newEndpointCreateCommand() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "create <name>",
17+
Short: "Create a switchover endpoint.",
18+
Args: cobra.ExactArgs(1),
19+
RunE: c.endpointCreate,
20+
Example: examples.BuildExampleString(
21+
examples.Example{
22+
Text: `Create a switchover endpoint "prod-kafka-dr-endpoint" bound to switchover pair "sw-123456".`,
23+
Code: "confluent switchover endpoint create prod-kafka-dr-endpoint --switchover-pair sw-123456 " +
24+
"--endpoint name=west-platt,resource-id=lkc-111111,type=PRIVATE,cloud=AWS,region=us-west-2,gateway=n-ccn-west-1 " +
25+
"--endpoint name=east-platt,resource-id=lkc-222222,type=PRIVATE,cloud=AWS,region=us-east-1,gateway=n-ccn-east-1",
26+
},
27+
),
28+
}
29+
30+
cmd.Flags().String("switchover-pair", "", "ID of the switchover pair this endpoint is bound to.")
31+
cmd.Flags().StringArray("endpoint", nil, `An endpoint definition as comma-separated "key=value" fields (keys: name, resource-id, type, cloud, region, gateway, access-point). Specify exactly twice.`)
32+
cmd.Flags().String("target", "", "Name of the endpoint that should start as active.")
33+
pcmd.AddContextFlag(cmd, c.CLICommand)
34+
pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand)
35+
pcmd.AddOutputFlag(cmd)
36+
37+
cobra.CheckErr(cmd.MarkFlagRequired("switchover-pair"))
38+
cobra.CheckErr(cmd.MarkFlagRequired("endpoint"))
39+
40+
return cmd
41+
}
42+
43+
func (c *command) endpointCreate(cmd *cobra.Command, args []string) error {
44+
displayName := args[0]
45+
46+
switchoverPairId, err := cmd.Flags().GetString("switchover-pair")
47+
if err != nil {
48+
return err
49+
}
50+
51+
endpointFlags, err := cmd.Flags().GetStringArray("endpoint")
52+
if err != nil {
53+
return err
54+
}
55+
if len(endpointFlags) != 2 {
56+
return fmt.Errorf("exactly two `--endpoint` flags must be specified, but received %d", len(endpointFlags))
57+
}
58+
59+
endpoints := make([]switchoverv1.SwitchoverV1EndpointConfig, len(endpointFlags))
60+
for i, raw := range endpointFlags {
61+
config, err := parseEndpoint(raw)
62+
if err != nil {
63+
return err
64+
}
65+
endpoints[i] = config
66+
}
67+
68+
environmentId, err := c.Context.EnvironmentId()
69+
if err != nil {
70+
return err
71+
}
72+
73+
spec := &switchoverv1.SwitchoverV1SwitchoverEndpointSpec{
74+
DisplayName: switchoverv1.PtrString(displayName),
75+
Endpoints: &endpoints,
76+
Environment: &switchoverv1.EnvScopedObjectReference{Id: environmentId},
77+
SwitchoverPair: &switchoverv1.EnvScopedObjectReference{Id: switchoverPairId},
78+
}
79+
80+
if cmd.Flags().Changed("target") {
81+
target, err := cmd.Flags().GetString("target")
82+
if err != nil {
83+
return err
84+
}
85+
spec.SetTarget(target)
86+
}
87+
88+
endpoint, err := c.V2Client.CreateSwitchoverEndpoint(switchoverv1.SwitchoverV1SwitchoverEndpoint{Spec: spec})
89+
if err != nil {
90+
return err
91+
}
92+
93+
return printEndpointTable(cmd, endpoint)
94+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package switchover
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
pcmd "github.com/confluentinc/cli/v4/pkg/cmd"
9+
"github.com/confluentinc/cli/v4/pkg/deletion"
10+
"github.com/confluentinc/cli/v4/pkg/output"
11+
"github.com/confluentinc/cli/v4/pkg/plural"
12+
"github.com/confluentinc/cli/v4/pkg/resource"
13+
"github.com/confluentinc/cli/v4/pkg/utils"
14+
)
15+
16+
func (c *command) newEndpointDeleteCommand() *cobra.Command {
17+
cmd := &cobra.Command{
18+
Use: "delete <id-1> [id-2] ... [id-n]",
19+
Short: "Delete one or more switchover endpoints.",
20+
Args: cobra.MinimumNArgs(1),
21+
ValidArgsFunction: pcmd.NewValidArgsFunction(c.validEndpointArgs),
22+
RunE: c.endpointDelete,
23+
}
24+
25+
pcmd.AddForceFlag(cmd)
26+
pcmd.AddContextFlag(cmd, c.CLICommand)
27+
pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand)
28+
29+
return cmd
30+
}
31+
32+
func (c *command) endpointDelete(cmd *cobra.Command, args []string) error {
33+
environmentId, err := c.Context.EnvironmentId()
34+
if err != nil {
35+
return err
36+
}
37+
38+
existenceFunc := func(id string) bool {
39+
_, err := c.V2Client.DescribeSwitchoverEndpoint(id, environmentId)
40+
return err == nil
41+
}
42+
43+
if err := deletion.ValidateAndConfirm(cmd, args, existenceFunc, resource.SwitchoverEndpoint); err != nil {
44+
return err
45+
}
46+
47+
deleteFunc := func(id string) error {
48+
return c.V2Client.DeleteSwitchoverEndpoint(id, environmentId)
49+
}
50+
51+
deletedIds, err := deletion.DeleteWithoutMessage(cmd, args, deleteFunc)
52+
deleteMsg := "Requested to delete %s %s.\n"
53+
if len(deletedIds) == 1 {
54+
output.Printf(c.Config.EnableColor, deleteMsg, resource.SwitchoverEndpoint, fmt.Sprintf(`"%s"`, deletedIds[0]))
55+
} else if len(deletedIds) > 1 {
56+
output.Printf(c.Config.EnableColor, deleteMsg, plural.Plural(resource.SwitchoverEndpoint), utils.ArrayToCommaDelimitedString(deletedIds, "and"))
57+
}
58+
59+
return err
60+
}

0 commit comments

Comments
 (0)