diff --git a/SWITCHOVER_LOCAL_DEMO.md b/SWITCHOVER_LOCAL_DEMO.md new file mode 100644 index 0000000000..100c881d74 --- /dev/null +++ b/SWITCHOVER_LOCAL_DEMO.md @@ -0,0 +1,195 @@ +# Local CLI build with Switchover support + +> Paths below assume this repo (and its sibling repos) are cloned under +> `~/sdk_cli_tf_switchover/`, matching the original workspace this branch was +> built in. Adjust paths if you clone elsewhere. + +How to reproduce the local `confluent` CLI build in this workspace, which adds +`confluent switchover pair` and `confluent switchover endpoint` commands on +top of the upstream CLI. + +## Workspace layout + +``` +~/sdk_cli_tf_switchover/ +├── cli/ +├── ccloud-sdk-go-v2-internal/ +├── api/ +├── cc-switchover/ +└── terraform-provider-confluent/ +``` + +- `cli/` — confluentinc/cli, branch `switchover-cli-local-demo`, + [PR #3399](https://github.com/confluentinc/cli/pull/3399) +- `ccloud-sdk-go-v2-internal/` — confluentinc/ccloud-sdk-go-v2-internal, + branch `ORC-10130-sync-switchover-sdk`, + [PR #832](https://github.com/confluentinc/ccloud-sdk-go-v2-internal/pull/832) +- `api/` — confluentinc/api, branch `ORC-10130-switchover-api-spec`, + [PR #2545](https://github.com/confluentinc/api/pull/2545) +- `cc-switchover/` — confluentinc/cc-switchover, branch `master` + (reference/source-of-truth) +- `terraform-provider-confluent/` — confluentinc/terraform-provider-confluent, + branch `master` (not used here) + +`cli`'s `go.mod` pins the switchover SDK to +[PR #832](https://github.com/confluentinc/ccloud-sdk-go-v2-internal/pull/832)'s +commit: + +``` +github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover v0.0.0-20260708221705-f2f8dbb55045 +``` + +To get the `cli` branch locally: + +```bash +git clone git@github.com:confluentinc/cli.git +cd cli +git checkout switchover-cli-local-demo +``` + +`confluentinc/cli` is a public repo with an org ruleset that blocks direct +branch pushes — use `git push-external` (Confluent's Airlock +proprietary-code-scan wrapper), not plain `git push`, to push further changes +to this branch. + +## One-time environment setup + +1. **Go toolchain** — `cli/.go-version` pins `1.26.4`. If your `goenv` doesn't + have it yet, any Go ≥1.21 binary will auto-fetch it via `GOTOOLCHAIN=auto`: + ```bash + export GOTOOLCHAIN=auto + export PATH="$HOME/.goenv/versions//bin:$PATH" + go version # should report go1.26.4 once it downloads + ``` +2. **goreleaser** — the CLI's `make build` shells out to it: + ```bash + brew install goreleaser + ``` +3. **Docker Desktop**, signed in to an org with access to + `519856050701.dkr.ecr.us-west-2.amazonaws.com` — only needed if you want to + regenerate the switchover SDK from `confluentinc/api`, not for building the + CLI itself. + +## Build + +```bash +cd ~/sdk_cli_tf_switchover/cli +export GOTOOLCHAIN=auto +export PATH="$HOME/.goenv/versions//bin:$PATH" +make build +``` + +Binary lands at: + +``` +~/sdk_cli_tf_switchover/cli/dist/confluent_darwin_arm64_v8.0/confluent +``` + +(path varies by OS/arch — check `dist/` after the build). + +## Alias the binary (recommended) + +Prefer a distinctly-named **alias** over adding the `dist/` folder to `PATH`, +so this experimental build doesn't shadow a real installed `confluent` CLI: + +```bash +alias confluent-dev="/Users/ewang/sdk_cli_tf_switchover/cli/dist/confluent_darwin_arm64_v8.0/confluent" +``` + +Already set up in `~/.aliases` (sourced from `~/.zshrc`), alongside the +existing `confluent` alias. New terminals pick it up automatically; in an +already-open terminal run `source ~/.aliases` once. The rest of this README +uses `confluent-dev`. + +## Usage + +Log in first — the `switchover` command requires cloud login: + +```bash +confluent-dev login +``` + +This hits **production** Confluent Cloud (`https://confluent.cloud`) with +real credentials — it's the CLI's stock login flow, not a mock. + +### Logging in against staging (`stag`) instead of production + +```bash +confluent-dev login --url https://stag.cpdev.cloud +``` + +The hostname is `stag.cpdev.cloud` with **no** `confluent.` prefix (unlike +prod's `confluent.cloud`). You'll need a **staging** Confluent Cloud +account — separate from your prod account — and your staging org needs +Switchover Early Access granted before `switchover pair` commands return +anything but an access/authorization error. `devel.cpdev.cloud` works the +same way for the `devel` environment. If you know your staging org ID, add +`--organization `. + +Everything below (`switchover pair`/`switchover endpoint` commands) works +identically once logged in, regardless of which environment you logged into. + +### Bypassing the stag EA gate with a Cloud API key + +The Switchover Early Access gate on `stag`/`devel` is only enforced on the +bearer-token (`login`) path — a Cloud API key (Basic auth) sails through it. +If your staging org doesn't have EA granted yet, set both of these and the +`switchover` commands will use Basic auth instead of your login session's +bearer token: + +```bash +export CONFLUENT_CLOUD_API_KEY= +export CONFLUENT_CLOUD_API_SECRET= +``` + +Create a Cloud API key first (still requires being logged in once, to create +it): `confluent-dev api-key create --resource cloud`. You still need +`confluent-dev login` for non-switchover commands / context; this env var +pair only affects the switchover API calls (see +`pkg/ccloudv2/switchover.go`'s `switchoverApiContext`). + +### Switchover pairs + +```bash +# Create a pair between two Kafka clusters +confluent-dev switchover pair create prod-kafka-dr \ + --member name=west,id=lkc-111111 \ + --member name=east,id=lkc-222222 \ + --active-member west \ + --environment env-abcdef + +# Describe it +confluent-dev switchover pair describe sw-123456 + +# Rename it (the only mutable field) +confluent-dev switchover pair update sw-123456 --display-name "prod-kafka-dr-renamed" + +# Trigger a failover (prompts for confirmation — moves live traffic) +confluent-dev switchover pair trigger-switch sw-123456 --active-member east --failover-type CLEAN +``` + +### Switchover endpoints + +```bash +confluent-dev switchover endpoint create prod-kafka-dr-endpoint \ + --switchover-pair sw-123456 \ + --endpoint name=west-platt,type=private \ + --endpoint name=east-platt,type=private + +confluent-dev switchover endpoint describe se-123456 +confluent-dev switchover endpoint update se-123456 --display-name "renamed-endpoint" +confluent-dev switchover endpoint activate se-123456 +``` + +### Global flags + +- `-o json` / `-o yaml` for machine-readable output (default is a human table). +- `--environment env-xxxxx` to override the current context's default environment. +- `--context ` to target a specific CLI context/profile. + +## Rebuilding after further code changes + +Rerun `make build` from `cli/` — it picks up any local edits. Only redo the +SDK pinning step (`go get github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover@` +in `cli/`) if `ccloud-sdk-go-v2-internal`'s switchover module changes again +upstream. diff --git a/go.mod b/go.mod index 10591c4663..30f43c701e 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/charmbracelet/lipgloss v0.11.0 github.com/client9/gospell v0.0.0-20160306015952-90dfc71015df github.com/confluentinc/ccloud-sdk-go-v1-public v0.0.0-20250521223017-0e8f6f971b52 + github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover v0.0.0-20260728173342-25f438c2593b github.com/confluentinc/ccloud-sdk-go-v2/ai v0.1.0 github.com/confluentinc/ccloud-sdk-go-v2/apikeys v0.4.0 github.com/confluentinc/ccloud-sdk-go-v2/billing v0.3.0 diff --git a/go.sum b/go.sum index c435e8645a..13c899cce4 100644 --- a/go.sum +++ b/go.sum @@ -172,6 +172,8 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/confluentinc/ccloud-sdk-go-v1-public v0.0.0-20250521223017-0e8f6f971b52 h1:19qEGhkbZa5fopKCe0VPIV+Sasby4Pv10z9ZaktwWso= github.com/confluentinc/ccloud-sdk-go-v1-public v0.0.0-20250521223017-0e8f6f971b52/go.mod h1:62EMf+5uFEt1BJ2q8WMrUoI9VUSxAbDnmZCGRt/MbA0= +github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover v0.0.0-20260728173342-25f438c2593b h1:9KvPeP2mC2MZNprKA1N2ueqCJEWLZAgRmVMIJdDXhAY= +github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover v0.0.0-20260728173342-25f438c2593b/go.mod h1:yv1VtjqyqD7G5gpdrzR2hGyIAfEOuLA3aTQW0TZ6Iek= github.com/confluentinc/ccloud-sdk-go-v2/ai v0.1.0 h1:zSF4OQUJXWH2JeAo9rsq13ibk+JFdzITGR8S7cFMpzw= github.com/confluentinc/ccloud-sdk-go-v2/ai v0.1.0/go.mod h1:DoxqzzF3JzvJr3fWkvCiOHFlE0GoYpozWxFZ1Ud9ntA= github.com/confluentinc/ccloud-sdk-go-v2/apikeys v0.4.0 h1:8fWyLwMuy8ec0MVF5Avd54UvbIxhDFhZzanHBVwgxdw= diff --git a/internal/command.go b/internal/command.go index 4b83164ff9..731207ac06 100644 --- a/internal/command.go +++ b/internal/command.go @@ -45,6 +45,7 @@ import ( "github.com/confluentinc/cli/v4/internal/secret" servicequota "github.com/confluentinc/cli/v4/internal/service-quota" streamshare "github.com/confluentinc/cli/v4/internal/stream-share" + "github.com/confluentinc/cli/v4/internal/switchover" "github.com/confluentinc/cli/v4/internal/tableflow" unifiedstreammanager "github.com/confluentinc/cli/v4/internal/unified-stream-manager" "github.com/confluentinc/cli/v4/internal/update" @@ -138,6 +139,7 @@ func NewConfluentCommand(cfg *config.Config) *cobra.Command { cmd.AddCommand(servicequota.New(prerunner)) cmd.AddCommand(shell.New(cmd, func() *cobra.Command { return NewConfluentCommand(cfg) })) cmd.AddCommand(streamshare.New(prerunner)) + cmd.AddCommand(switchover.New(prerunner)) cmd.AddCommand(tableflow.New(prerunner)) cmd.AddCommand(unifiedstreammanager.New(cfg, prerunner)) cmd.AddCommand(update.New(cfg, prerunner)) diff --git a/internal/switchover/command.go b/internal/switchover/command.go new file mode 100644 index 0000000000..d2a9f1515b --- /dev/null +++ b/internal/switchover/command.go @@ -0,0 +1,22 @@ +package switchover + +import ( + "github.com/spf13/cobra" + + "github.com/confluentinc/cli/v4/internal/switchover/endpoint" + "github.com/confluentinc/cli/v4/internal/switchover/pair" + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" +) + +func New(prerunner pcmd.PreRunner) *cobra.Command { + cmd := &cobra.Command{ + Use: "switchover", + Short: "Manage Kafka disaster recovery switchover pairs and endpoints.", + Annotations: map[string]string{pcmd.RunRequirement: pcmd.RequireNonAPIKeyCloudLogin}, + } + + cmd.AddCommand(pair.New(prerunner)) + cmd.AddCommand(endpoint.New(prerunner)) + + return cmd +} diff --git a/internal/switchover/endpoint/command.go b/internal/switchover/endpoint/command.go new file mode 100644 index 0000000000..b39c6b738e --- /dev/null +++ b/internal/switchover/endpoint/command.go @@ -0,0 +1,135 @@ +package endpoint + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/tidwall/pretty" + "gopkg.in/yaml.v3" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/output" +) + +type command struct { + *pcmd.AuthenticatedCLICommand +} + +// out is the detailed human-readable shape used by create/describe/update. +// Machine-readable output (`-o json` / `-o yaml`) is emitted straight from the +// SDK object so it matches the API response verbatim. +type out struct { + Id string `human:"ID"` + DisplayName string `human:"Display Name"` + SwitchoverPair string `human:"Switchover Pair"` + Environment string `human:"Environment"` + Target string `human:"Target,omitempty"` + Phase string `human:"Phase"` + Endpoints string `human:"Endpoints,omitempty"` + Conditions string `human:"Conditions,omitempty"` +} + +// listOut is the compact per-row shape for `list` (human output only). +type listOut struct { + Id string `human:"ID"` + DisplayName string `human:"Display Name"` + SwitchoverPair string `human:"Switchover Pair"` + Environment string `human:"Environment"` + Phase string `human:"Phase"` +} + +func New(prerunner pcmd.PreRunner) *cobra.Command { + cmd := &cobra.Command{ + Use: "endpoint", + Short: "Manage switchover endpoints.", + Long: "Manage switchover endpoints. This API is not yet implemented on the backend; commands will fail against a live Confluent Cloud environment.", + } + + c := &command{pcmd.NewAuthenticatedCLICommand(cmd, prerunner)} + + cmd.AddCommand(c.newCreateCommand()) + cmd.AddCommand(c.newDeleteCommand()) + cmd.AddCommand(c.newDescribeCommand()) + cmd.AddCommand(c.newListCommand()) + cmd.AddCommand(c.newUpdateCommand()) + + return cmd +} + +// printSerialized emits v as JSON or YAML using the SDK object's `json` tags, +// so the output matches the API response verbatim. (output.SerializedOutput's +// YAML path uses yaml.v3, which ignores `json` tags; route YAML through the +// JSON representation to preserve the API field names.) +func printSerialized(cmd *cobra.Command, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + + if output.GetFormat(cmd) == output.YAML { + var generic any + if err := json.Unmarshal(data, &generic); err != nil { + return err + } + out, err := yaml.Marshal(generic) + if err != nil { + return err + } + output.Print(false, string(out)) + return nil + } + + output.Print(false, string(pretty.Pretty(data))) + return nil +} + +func newEndpointOut(endpoint switchoverv1.SwitchoverV1SwitchoverEndpoint) *out { + return &out{ + Id: endpoint.GetId(), + DisplayName: endpoint.Spec.GetDisplayName(), + SwitchoverPair: endpoint.Spec.GetParentResourceId(), + Environment: endpoint.Spec.GetEnvironment(), + Target: endpoint.Spec.GetTarget(), + Phase: endpoint.Status.GetPhase(), + Endpoints: formatEndpoints(endpoint.Spec.GetEndpoints()), + Conditions: formatConditions(endpoint.Status.GetConditions()), + } +} + +func formatEndpoints(endpoints []switchoverv1.SwitchoverV1EndpointConfig) string { + lines := make([]string, len(endpoints)) + for i, endpoint := range endpoints { + filter := endpoint.EndpointFilter + parts := []string{endpoint.GetName(), filter.GetType()} + if networkId := filter.GetNetworkId(); networkId != "" { + parts = append(parts, "network="+networkId) + } + if accessPoint := filter.GetAccessPoint(); accessPoint != "" { + parts = append(parts, "access-point="+accessPoint) + } + if hostname := endpoint.GetHostname(); hostname != "" { + parts = append(parts, "hostname="+hostname) + } + lines[i] = strings.Join(parts, " ") + } + return strings.Join(lines, "\n") +} + +func formatConditions(conditions []switchoverv1.SwitchoverV1Condition) string { + lines := make([]string, len(conditions)) + for i, condition := range conditions { + line := fmt.Sprintf("%s=%s", condition.GetType(), condition.GetStatus()) + if reason := condition.GetReason(); reason != "" { + line += fmt.Sprintf(" (%s)", reason) + } + if message := condition.GetMessage(); message != "" { + line += ": " + message + } + lines[i] = line + } + return strings.Join(lines, "\n") +} diff --git a/internal/switchover/endpoint/command_create.go b/internal/switchover/endpoint/command_create.go new file mode 100644 index 0000000000..fcedfdc540 --- /dev/null +++ b/internal/switchover/endpoint/command_create.go @@ -0,0 +1,127 @@ +package endpoint + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/examples" + "github.com/confluentinc/cli/v4/pkg/output" +) + +func (c *command) newCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a switchover endpoint.", + Long: "Create a switchover endpoint bound to a switchover pair.", + Args: cobra.ExactArgs(1), + RunE: c.create, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Create switchover endpoint "prod-kafka-dr-endpoint" for switchover pair "sw-123456".`, + Code: `confluent switchover endpoint create prod-kafka-dr-endpoint --switchover-pair sw-123456 --endpoint name=west-platt,type=private --endpoint name=east-platt,type=private`, + }, + ), + } + + cmd.Flags().String("switchover-pair", "", "The ID of the switchover pair this endpoint is bound to.") + cmd.Flags().StringArray("endpoint", nil, `An endpoint side, in the form "name=,type=[,network=][,access-point=]". Must be specified exactly twice.`) + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + cobra.CheckErr(cmd.MarkFlagRequired("switchover-pair")) + cobra.CheckErr(cmd.MarkFlagRequired("endpoint")) + + return cmd +} + +func parseEndpointFlag(raw string) (switchoverv1.SwitchoverV1EndpointConfig, error) { + config := switchoverv1.SwitchoverV1EndpointConfig{} + filter := switchoverv1.SwitchoverV1EndpointFilter{} + for _, part := range strings.Split(raw, ",") { + key, value, ok := strings.Cut(part, "=") + if !ok { + return config, fmt.Errorf(`invalid --endpoint value %q: expected "key=value" pairs`, raw) + } + switch key { + case "name": + config.Name = value + case "type": + filter.Type = value + case "network": + filter.NetworkId = switchoverv1.PtrString(value) + case "access-point": + filter.AccessPoint = switchoverv1.PtrString(value) + default: + return config, fmt.Errorf(`invalid --endpoint key %q`, key) + } + } + if config.Name == "" || filter.Type == "" { + return config, fmt.Errorf(`invalid --endpoint value %q: "name" and "type" are required`, raw) + } + config.EndpointFilter = filter + return config, nil +} + +func (c *command) create(cmd *cobra.Command, args []string) error { + displayName := args[0] + + switchoverPairId, err := cmd.Flags().GetString("switchover-pair") + if err != nil { + return err + } + + rawEndpoints, err := cmd.Flags().GetStringArray("endpoint") + if err != nil { + return err + } + if len(rawEndpoints) != 2 { + return fmt.Errorf(`exactly two --endpoint flags are required, got %d`, len(rawEndpoints)) + } + + endpoints := make([]switchoverv1.SwitchoverV1EndpointConfig, len(rawEndpoints)) + for i, raw := range rawEndpoints { + endpointConfig, err := parseEndpointFlag(raw) + if err != nil { + return err + } + endpoints[i] = endpointConfig + } + + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + endpoint := switchoverv1.SwitchoverV1SwitchoverEndpoint{ + Spec: &switchoverv1.SwitchoverV1SwitchoverEndpointSpec{ + DisplayName: switchoverv1.PtrString(displayName), + Endpoints: &endpoints, + Environment: switchoverv1.PtrString(environmentId), + ParentResourceId: switchoverv1.PtrString(switchoverPairId), + }, + } + + result, err := c.V2Client.CreateSwitchoverEndpoint(endpoint) + if err != nil { + return err + } + + return printSwitchoverEndpoint(cmd, result) +} + +func printSwitchoverEndpoint(cmd *cobra.Command, endpoint switchoverv1.SwitchoverV1SwitchoverEndpoint) error { + // Serialized output mirrors the API response verbatim (full spec/status). + if output.GetFormat(cmd).IsSerialized() { + return printSerialized(cmd, endpoint) + } + + table := output.NewTable(cmd) + table.Add(newEndpointOut(endpoint)) + return table.Print() +} diff --git a/internal/switchover/endpoint/command_delete.go b/internal/switchover/endpoint/command_delete.go new file mode 100644 index 0000000000..497cf1ddaf --- /dev/null +++ b/internal/switchover/endpoint/command_delete.go @@ -0,0 +1,55 @@ +package endpoint + +import ( + "github.com/spf13/cobra" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/deletion" + "github.com/confluentinc/cli/v4/pkg/examples" + "github.com/confluentinc/cli/v4/pkg/resource" +) + +func (c *command) newDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [id-2] ... [id-n]", + Short: "Delete one or more switchover endpoints.", + Args: cobra.MinimumNArgs(1), + RunE: c.delete, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Delete switchover endpoint "se-123456" in the current environment.`, + Code: "confluent switchover endpoint delete se-123456", + }, + ), + } + + pcmd.AddForceFlag(cmd) + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + return cmd +} + +func (c *command) delete(cmd *cobra.Command, args []string) error { + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + existenceFunc := func(id string) bool { + _, err := c.V2Client.GetSwitchoverEndpoint(id, environmentId) + return err == nil + } + + if err := deletion.ValidateAndConfirm(cmd, args, existenceFunc, resource.SwitchoverEndpoint); err != nil { + return err + } + + deleteFunc := func(id string) error { + return c.V2Client.DeleteSwitchoverEndpoint(id, environmentId) + } + + _, err = deletion.Delete(cmd, args, deleteFunc, resource.SwitchoverEndpoint) + return err +} diff --git a/internal/switchover/endpoint/command_describe.go b/internal/switchover/endpoint/command_describe.go new file mode 100644 index 0000000000..ea0386cf24 --- /dev/null +++ b/internal/switchover/endpoint/command_describe.go @@ -0,0 +1,43 @@ +package endpoint + +import ( + "github.com/spf13/cobra" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/examples" +) + +func (c *command) newDescribeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "describe ", + Short: "Describe a switchover endpoint.", + Args: cobra.ExactArgs(1), + RunE: c.describe, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Describe switchover endpoint "se-123456".`, + Code: `confluent switchover endpoint describe se-123456`, + }, + ), + } + + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + return cmd +} + +func (c *command) describe(cmd *cobra.Command, args []string) error { + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + endpoint, err := c.V2Client.GetSwitchoverEndpoint(args[0], environmentId) + if err != nil { + return err + } + + return printSwitchoverEndpoint(cmd, endpoint) +} diff --git a/internal/switchover/endpoint/command_list.go b/internal/switchover/endpoint/command_list.go new file mode 100644 index 0000000000..ceabfb76b1 --- /dev/null +++ b/internal/switchover/endpoint/command_list.go @@ -0,0 +1,74 @@ +package endpoint + +import ( + "github.com/spf13/cobra" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/examples" + "github.com/confluentinc/cli/v4/pkg/output" +) + +func (c *command) newListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List switchover endpoints.", + Args: cobra.NoArgs, + RunE: c.list, + Example: examples.BuildExampleString( + examples.Example{ + Text: "List switchover endpoints in the current environment.", + Code: "confluent switchover endpoint list", + }, + examples.Example{ + Text: `List switchover endpoints for switchover pair "sw-123456".`, + Code: "confluent switchover endpoint list --switchover-pair sw-123456", + }, + ), + } + + cmd.Flags().String("switchover-pair", "", "Filter the results by the associated switchover pair ID.") + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + return cmd +} + +func (c *command) list(cmd *cobra.Command, _ []string) error { + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + switchoverPair, err := cmd.Flags().GetString("switchover-pair") + if err != nil { + return err + } + + endpoints, err := c.V2Client.ListSwitchoverEndpoints(environmentId, switchoverPair) + if err != nil { + return err + } + + // Serialized output mirrors the API response verbatim (full spec/status). + if output.GetFormat(cmd).IsSerialized() { + if endpoints == nil { + endpoints = []switchoverv1.SwitchoverV1SwitchoverEndpoint{} + } + return printSerialized(cmd, endpoints) + } + + list := output.NewList(cmd) + for _, endpoint := range endpoints { + list.Add(&listOut{ + Id: endpoint.GetId(), + DisplayName: endpoint.Spec.GetDisplayName(), + SwitchoverPair: endpoint.Spec.GetParentResourceId(), + Environment: endpoint.Spec.GetEnvironment(), + Phase: endpoint.Status.GetPhase(), + }) + } + return list.Print() +} diff --git a/internal/switchover/endpoint/command_update.go b/internal/switchover/endpoint/command_update.go new file mode 100644 index 0000000000..9cb0bff91b --- /dev/null +++ b/internal/switchover/endpoint/command_update.go @@ -0,0 +1,60 @@ +package endpoint + +import ( + "github.com/spf13/cobra" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/examples" +) + +func (c *command) newUpdateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a switchover endpoint.", + Long: "Update a switchover endpoint's display name. This is the only mutable field.", + Args: cobra.ExactArgs(1), + RunE: c.update, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Rename switchover endpoint "se-123456".`, + Code: `confluent switchover endpoint update se-123456 --display-name "prod-kafka-dr-endpoint-renamed"`, + }, + ), + } + + cmd.Flags().String("display-name", "", "A human-readable name for the switchover endpoint.") + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + cobra.CheckErr(cmd.MarkFlagRequired("display-name")) + + return cmd +} + +func (c *command) update(cmd *cobra.Command, args []string) error { + displayName, err := cmd.Flags().GetString("display-name") + if err != nil { + return err + } + + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + endpoint := switchoverv1.SwitchoverV1SwitchoverEndpointUpdateRequest{ + Spec: switchoverv1.SwitchoverV1SwitchoverEndpointUpdateRequestSpec{ + DisplayName: switchoverv1.PtrString(displayName), + }, + } + + result, err := c.V2Client.UpdateSwitchoverEndpoint(args[0], environmentId, endpoint) + if err != nil { + return err + } + + return printSwitchoverEndpoint(cmd, result) +} diff --git a/internal/switchover/pair/command.go b/internal/switchover/pair/command.go new file mode 100644 index 0000000000..14300e7a49 --- /dev/null +++ b/internal/switchover/pair/command.go @@ -0,0 +1,129 @@ +package pair + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/tidwall/pretty" + "gopkg.in/yaml.v3" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/output" +) + +type command struct { + *pcmd.AuthenticatedCLICommand +} + +// out is the detailed human-readable shape used by create/describe/update/ +// trigger-switch. Machine-readable output (`-o json` / `-o yaml`) is emitted +// straight from the SDK object so it matches the API response verbatim. +type out struct { + Id string `human:"ID"` + DisplayName string `human:"Display Name"` + Environment string `human:"Environment"` + ActiveMember string `human:"Active Member"` + FailoverType string `human:"Failover Type,omitempty"` + Phase string `human:"Phase"` + Members string `human:"Members,omitempty"` + Conditions string `human:"Conditions,omitempty"` +} + +// listOut is the compact per-row shape for `list` (human output only). +type listOut struct { + Id string `human:"ID"` + DisplayName string `human:"Display Name"` + Environment string `human:"Environment"` + ActiveMember string `human:"Active Member"` + FailoverType string `human:"Failover Type"` + Phase string `human:"Phase"` +} + +func New(prerunner pcmd.PreRunner) *cobra.Command { + cmd := &cobra.Command{ + Use: "pair", + Short: "Manage switchover pairs.", + } + + c := &command{pcmd.NewAuthenticatedCLICommand(cmd, prerunner)} + + cmd.AddCommand(c.newCreateCommand()) + cmd.AddCommand(c.newDeleteCommand()) + cmd.AddCommand(c.newDescribeCommand()) + cmd.AddCommand(c.newListCommand()) + cmd.AddCommand(c.newUpdateCommand()) + cmd.AddCommand(c.newTriggerSwitchCommand()) + + return cmd +} + +// printSerialized emits v as JSON or YAML using the SDK object's `json` tags, +// so the output matches the API response verbatim. (output.SerializedOutput's +// YAML path uses yaml.v3, which ignores `json` tags; route YAML through the +// JSON representation to preserve the API field names.) +func printSerialized(cmd *cobra.Command, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + + if output.GetFormat(cmd) == output.YAML { + var generic any + if err := json.Unmarshal(data, &generic); err != nil { + return err + } + out, err := yaml.Marshal(generic) + if err != nil { + return err + } + output.Print(false, string(out)) + return nil + } + + output.Print(false, string(pretty.Pretty(data))) + return nil +} + +func newPairOut(pair switchoverv1.SwitchoverV1SwitchoverPair) *out { + return &out{ + Id: pair.GetId(), + DisplayName: pair.Spec.GetDisplayName(), + Environment: pair.Spec.GetEnvironment(), + ActiveMember: pair.Spec.GetActiveMember(), + FailoverType: pair.Spec.GetFailoverType(), + Phase: pair.Status.GetPhase(), + Members: formatMembers(pair.Spec.GetMembers()), + Conditions: formatConditions(pair.Status.GetConditions()), + } +} + +func formatMembers(members []switchoverv1.SwitchoverV1SwitchoverPairMember) string { + lines := make([]string, len(members)) + for i, member := range members { + location := "" + if member.Location != nil { + location = fmt.Sprintf(", %s/%s", member.Location.GetCloud(), member.Location.GetRegion()) + } + lines[i] = fmt.Sprintf("%s (%s%s)", member.GetName(), member.GetMemberId(), location) + } + return strings.Join(lines, "\n") +} + +func formatConditions(conditions []switchoverv1.SwitchoverV1Condition) string { + lines := make([]string, len(conditions)) + for i, condition := range conditions { + line := fmt.Sprintf("%s=%s", condition.GetType(), condition.GetStatus()) + if reason := condition.GetReason(); reason != "" { + line += fmt.Sprintf(" (%s)", reason) + } + if message := condition.GetMessage(); message != "" { + line += ": " + message + } + lines[i] = line + } + return strings.Join(lines, "\n") +} diff --git a/internal/switchover/pair/command_create.go b/internal/switchover/pair/command_create.go new file mode 100644 index 0000000000..7af14e2a62 --- /dev/null +++ b/internal/switchover/pair/command_create.go @@ -0,0 +1,121 @@ +package pair + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/examples" + "github.com/confluentinc/cli/v4/pkg/output" +) + +func (c *command) newCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a switchover pair.", + Long: "Create a switchover pair between two Kafka clusters for disaster recovery.", + Args: cobra.ExactArgs(1), + RunE: c.create, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Create switchover pair "prod-kafka-dr" between clusters "lkc-111111" (west) and "lkc-222222" (east), active on west.`, + Code: `confluent switchover pair create prod-kafka-dr --member name=west,id=lkc-111111 --member name=east,id=lkc-222222 --active-member west`, + }, + ), + } + + cmd.Flags().StringArray("member", nil, `A member of the pair, in the form "name=,id=". Must be specified exactly twice.`) + cmd.Flags().String("active-member", "", "The name of the member that starts as active; must match one of the --member names.") + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + cobra.CheckErr(cmd.MarkFlagRequired("member")) + cobra.CheckErr(cmd.MarkFlagRequired("active-member")) + + return cmd +} + +func parseMemberFlag(raw string) (switchoverv1.SwitchoverV1SwitchoverPairMember, error) { + member := switchoverv1.SwitchoverV1SwitchoverPairMember{} + for _, part := range strings.Split(raw, ",") { + key, value, ok := strings.Cut(part, "=") + if !ok { + return member, fmt.Errorf(`invalid --member value %q: expected "name=,id="`, raw) + } + switch key { + case "name": + member.Name = value + case "id": + member.MemberId = value + default: + return member, fmt.Errorf(`invalid --member key %q: expected "name" or "id"`, key) + } + } + if member.Name == "" || member.MemberId == "" { + return member, fmt.Errorf(`invalid --member value %q: both "name" and "id" are required`, raw) + } + return member, nil +} + +func (c *command) create(cmd *cobra.Command, args []string) error { + displayName := args[0] + + rawMembers, err := cmd.Flags().GetStringArray("member") + if err != nil { + return err + } + if len(rawMembers) != 2 { + return fmt.Errorf(`exactly two --member flags are required, got %d`, len(rawMembers)) + } + + members := make([]switchoverv1.SwitchoverV1SwitchoverPairMember, len(rawMembers)) + for i, raw := range rawMembers { + member, err := parseMemberFlag(raw) + if err != nil { + return err + } + members[i] = member + } + + activeMember, err := cmd.Flags().GetString("active-member") + if err != nil { + return err + } + + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + pair := switchoverv1.SwitchoverV1SwitchoverPair{ + Spec: &switchoverv1.SwitchoverV1SwitchoverPairSpec{ + DisplayName: switchoverv1.PtrString(displayName), + Members: &members, + ActiveMember: switchoverv1.PtrString(activeMember), + Environment: switchoverv1.PtrString(environmentId), + }, + } + + result, err := c.V2Client.CreateSwitchoverPair(pair) + if err != nil { + return err + } + + return printSwitchoverPair(cmd, result) +} + +func printSwitchoverPair(cmd *cobra.Command, pair switchoverv1.SwitchoverV1SwitchoverPair) error { + // Serialized output mirrors the API response verbatim (full spec/status). + if output.GetFormat(cmd).IsSerialized() { + return printSerialized(cmd, pair) + } + + table := output.NewTable(cmd) + table.Add(newPairOut(pair)) + return table.Print() +} diff --git a/internal/switchover/pair/command_delete.go b/internal/switchover/pair/command_delete.go new file mode 100644 index 0000000000..e3d1e0bef4 --- /dev/null +++ b/internal/switchover/pair/command_delete.go @@ -0,0 +1,55 @@ +package pair + +import ( + "github.com/spf13/cobra" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/deletion" + "github.com/confluentinc/cli/v4/pkg/examples" + "github.com/confluentinc/cli/v4/pkg/resource" +) + +func (c *command) newDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [id-2] ... [id-n]", + Short: "Delete one or more switchover pairs.", + Args: cobra.MinimumNArgs(1), + RunE: c.delete, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Delete switchover pair "sw-123456" in the current environment.`, + Code: "confluent switchover pair delete sw-123456", + }, + ), + } + + pcmd.AddForceFlag(cmd) + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + return cmd +} + +func (c *command) delete(cmd *cobra.Command, args []string) error { + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + existenceFunc := func(id string) bool { + _, err := c.V2Client.GetSwitchoverPair(id, environmentId) + return err == nil + } + + if err := deletion.ValidateAndConfirm(cmd, args, existenceFunc, resource.SwitchoverPair); err != nil { + return err + } + + deleteFunc := func(id string) error { + return c.V2Client.DeleteSwitchoverPair(id, environmentId) + } + + _, err = deletion.Delete(cmd, args, deleteFunc, resource.SwitchoverPair) + return err +} diff --git a/internal/switchover/pair/command_describe.go b/internal/switchover/pair/command_describe.go new file mode 100644 index 0000000000..058c2a8bef --- /dev/null +++ b/internal/switchover/pair/command_describe.go @@ -0,0 +1,43 @@ +package pair + +import ( + "github.com/spf13/cobra" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/examples" +) + +func (c *command) newDescribeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "describe ", + Short: "Describe a switchover pair.", + Args: cobra.ExactArgs(1), + RunE: c.describe, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Describe switchover pair "sw-123456".`, + Code: `confluent switchover pair describe sw-123456`, + }, + ), + } + + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + return cmd +} + +func (c *command) describe(cmd *cobra.Command, args []string) error { + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + pair, err := c.V2Client.GetSwitchoverPair(args[0], environmentId) + if err != nil { + return err + } + + return printSwitchoverPair(cmd, pair) +} diff --git a/internal/switchover/pair/command_list.go b/internal/switchover/pair/command_list.go new file mode 100644 index 0000000000..fbbbae80d9 --- /dev/null +++ b/internal/switchover/pair/command_list.go @@ -0,0 +1,69 @@ +package pair + +import ( + "github.com/spf13/cobra" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/examples" + "github.com/confluentinc/cli/v4/pkg/output" +) + +func (c *command) newListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List switchover pairs.", + Args: cobra.NoArgs, + RunE: c.list, + Example: examples.BuildExampleString( + examples.Example{ + Text: "List switchover pairs in the current environment.", + Code: "confluent switchover pair list", + }, + examples.Example{ + Text: `List switchover pairs in environment "env-123456".`, + Code: "confluent switchover pair list --environment env-123456", + }, + ), + } + + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + return cmd +} + +func (c *command) list(cmd *cobra.Command, _ []string) error { + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + pairs, err := c.V2Client.ListSwitchoverPairs(environmentId) + if err != nil { + return err + } + + // Serialized output mirrors the API response verbatim (full spec/status). + if output.GetFormat(cmd).IsSerialized() { + if pairs == nil { + pairs = []switchoverv1.SwitchoverV1SwitchoverPair{} + } + return printSerialized(cmd, pairs) + } + + list := output.NewList(cmd) + for _, pair := range pairs { + list.Add(&listOut{ + Id: pair.GetId(), + DisplayName: pair.Spec.GetDisplayName(), + Environment: pair.Spec.GetEnvironment(), + ActiveMember: pair.Spec.GetActiveMember(), + FailoverType: pair.Spec.GetFailoverType(), + Phase: pair.Status.GetPhase(), + }) + } + return list.Print() +} diff --git a/internal/switchover/pair/command_trigger_switch.go b/internal/switchover/pair/command_trigger_switch.go new file mode 100644 index 0000000000..34171e1e15 --- /dev/null +++ b/internal/switchover/pair/command_trigger_switch.go @@ -0,0 +1,79 @@ +package pair + +import ( + "fmt" + + "github.com/spf13/cobra" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/deletion" + "github.com/confluentinc/cli/v4/pkg/examples" +) + +func (c *command) newTriggerSwitchCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "trigger-switch ", + Short: "Trigger a failover or switchback on a switchover pair.", + Long: "Trigger a failover (or switchback) on a switchover pair. This redirects live traffic between the pair's members.", + Args: cobra.ExactArgs(1), + RunE: c.triggerSwitch, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Fail switchover pair "sw-123456" over to its "east" member.`, + Code: `confluent switchover pair trigger-switch sw-123456 --active-member east`, + }, + ), + } + + cmd.Flags().String("active-member", "", "The name of the member to promote to active. If omitted, the other member is promoted.") + cmd.Flags().String("failover-type", "CLEAN", "The failover semantics to apply: CLEAN, UNCLEAN, or RESTORE.") + cmd.Flags().Bool("force", false, "Skip the confirmation prompt.") + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + return cmd +} + +func (c *command) triggerSwitch(cmd *cobra.Command, args []string) error { + id := args[0] + + activeMember, err := cmd.Flags().GetString("active-member") + if err != nil { + return err + } + + failoverType, err := cmd.Flags().GetString("failover-type") + if err != nil { + return err + } + + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + promptMsg := fmt.Sprintf(`This triggers a %s failover on switchover pair "%s", redirecting live traffic between regions. Do you want to proceed?`, failoverType, id) + if err := deletion.ConfirmPrompt(cmd, promptMsg); err != nil { + return err + } + + req := switchoverv1.SwitchoverV1SwitchoverPairFailoverRequest{ + Spec: switchoverv1.SwitchoverV1SwitchoverPairFailoverRequestSpec{ + Environment: environmentId, + FailoverType: switchoverv1.PtrString(failoverType), + }, + } + if activeMember != "" { + req.Spec.ActiveMember = switchoverv1.PtrString(activeMember) + } + + result, err := c.V2Client.TriggerSwitchoverPairFailover(id, req) + if err != nil { + return err + } + + return printSwitchoverPair(cmd, result) +} diff --git a/internal/switchover/pair/command_update.go b/internal/switchover/pair/command_update.go new file mode 100644 index 0000000000..83e4817c72 --- /dev/null +++ b/internal/switchover/pair/command_update.go @@ -0,0 +1,60 @@ +package pair + +import ( + "github.com/spf13/cobra" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + pcmd "github.com/confluentinc/cli/v4/pkg/cmd" + "github.com/confluentinc/cli/v4/pkg/examples" +) + +func (c *command) newUpdateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a switchover pair.", + Long: "Update a switchover pair's display name. This is the only mutable field.", + Args: cobra.ExactArgs(1), + RunE: c.update, + Example: examples.BuildExampleString( + examples.Example{ + Text: `Rename switchover pair "sw-123456".`, + Code: `confluent switchover pair update sw-123456 --display-name "prod-kafka-dr-renamed"`, + }, + ), + } + + cmd.Flags().String("display-name", "", "A human-readable name for the switchover pair.") + pcmd.AddEnvironmentFlag(cmd, c.AuthenticatedCLICommand) + pcmd.AddContextFlag(cmd, c.CLICommand) + pcmd.AddOutputFlag(cmd) + + cobra.CheckErr(cmd.MarkFlagRequired("display-name")) + + return cmd +} + +func (c *command) update(cmd *cobra.Command, args []string) error { + displayName, err := cmd.Flags().GetString("display-name") + if err != nil { + return err + } + + environmentId, err := c.Context.EnvironmentId() + if err != nil { + return err + } + + pair := switchoverv1.SwitchoverV1SwitchoverPairUpdateRequest{ + Spec: switchoverv1.SwitchoverV1SwitchoverPairUpdateRequestSpec{ + DisplayName: switchoverv1.PtrString(displayName), + }, + } + + result, err := c.V2Client.UpdateSwitchoverPair(args[0], environmentId, pair) + if err != nil { + return err + } + + return printSwitchoverPair(cmd, result) +} diff --git a/pkg/ccloudv2/client.go b/pkg/ccloudv2/client.go index a736857f5b..26e2f70803 100644 --- a/pkg/ccloudv2/client.go +++ b/pkg/ccloudv2/client.go @@ -38,6 +38,8 @@ import ( tableflowv1 "github.com/confluentinc/ccloud-sdk-go-v2/tableflow/v1" usmv1 "github.com/confluentinc/ccloud-sdk-go-v2/usm/v1" + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + "github.com/confluentinc/cli/v4/pkg/config" testserver "github.com/confluentinc/cli/v4/test/test-server" ) @@ -80,6 +82,7 @@ type Client struct { ServiceQuotaClient *servicequotav1.APIClient SrcmClient *srcmv3.APIClient SsoClient *ssov2.APIClient + SwitchoverClient *switchoverv1.APIClient TableflowClient *tableflowv1.APIClient UsmClient *usmv1.APIClient // cli-tfgen:cli-client-fields — DO NOT REMOVE (verified by TestCliTfgenMarkers) @@ -132,6 +135,7 @@ func NewClient(cfg *config.Config, unsafeTrace bool) *Client { ServiceQuotaClient: newServiceQuotaClient(httpClient, url, userAgent, unsafeTrace), SrcmClient: newSrcmClient(httpClient, url, userAgent, unsafeTrace), SsoClient: newSsoClient(httpClient, url, userAgent, unsafeTrace), + SwitchoverClient: newSwitchoverClient(httpClient, url, userAgent, unsafeTrace), TableflowClient: newTableflowClient(httpClient, url, userAgent, unsafeTrace), UsmClient: newUsmClient(httpClient, url, userAgent, unsafeTrace), // cli-tfgen:cli-client-init — DO NOT REMOVE (verified by TestCliTfgenMarkers) diff --git a/pkg/ccloudv2/switchover.go b/pkg/ccloudv2/switchover.go new file mode 100644 index 0000000000..6a067be74f --- /dev/null +++ b/pkg/ccloudv2/switchover.go @@ -0,0 +1,139 @@ +package ccloudv2 + +import ( + "context" + "net/http" + "os" + + switchoverv1 "github.com/confluentinc/ccloud-sdk-go-v2-internal/switchover/v1" + + "github.com/confluentinc/cli/v4/pkg/errors" +) + +func newSwitchoverClient(httpClient *http.Client, url, userAgent string, unsafeTrace bool) *switchoverv1.APIClient { + cfg := switchoverv1.NewConfiguration() + cfg.Debug = unsafeTrace + cfg.HTTPClient = httpClient + cfg.Servers = switchoverv1.ServerConfigurations{{URL: url}} + cfg.UserAgent = userAgent + + return switchoverv1.NewAPIClient(cfg) +} + +// switchoverApiContext normally authenticates with the logged-in session's +// bearer token. Local-test-only escape hatch: if CONFLUENT_CLOUD_API_KEY and +// CONFLUENT_CLOUD_API_SECRET (a Cloud API key, `api-key create --resource +// cloud`) are set, use Basic auth instead — the stag Switchover Early Access +// gate only applies to the bearer/login path, not Cloud API keys. +func (c *Client) switchoverApiContext() context.Context { + if key, secret := os.Getenv("CONFLUENT_CLOUD_API_KEY"), os.Getenv("CONFLUENT_CLOUD_API_SECRET"); key != "" && secret != "" { + return context.WithValue(context.Background(), switchoverv1.ContextBasicAuth, switchoverv1.BasicAuth{UserName: key, Password: secret}) + } + return context.WithValue(context.Background(), switchoverv1.ContextAccessToken, c.cfg.Context().GetAuthToken()) +} + +func (c *Client) CreateSwitchoverPair(pair switchoverv1.SwitchoverV1SwitchoverPair) (switchoverv1.SwitchoverV1SwitchoverPair, error) { + res, httpResp, err := c.SwitchoverClient.SwitchoverPairsSwitchoverV1Api.CreateSwitchoverV1SwitchoverPair(c.switchoverApiContext()).SwitchoverV1SwitchoverPair(pair).Execute() + return res, errors.CatchCCloudV2Error(err, httpResp) +} + +func (c *Client) GetSwitchoverPair(id, environment string) (switchoverv1.SwitchoverV1SwitchoverPair, error) { + res, httpResp, err := c.SwitchoverClient.SwitchoverPairsSwitchoverV1Api.GetSwitchoverV1SwitchoverPair(c.switchoverApiContext(), id).Environment(environment).Execute() + return res, errors.CatchCCloudV2Error(err, httpResp) +} + +func (c *Client) UpdateSwitchoverPair(id, environment string, update switchoverv1.SwitchoverV1SwitchoverPairUpdateRequest) (switchoverv1.SwitchoverV1SwitchoverPair, error) { + res, httpResp, err := c.SwitchoverClient.SwitchoverPairsSwitchoverV1Api.UpdateSwitchoverV1SwitchoverPair(c.switchoverApiContext(), id).Environment(environment).SwitchoverV1SwitchoverPairUpdateRequest(update).Execute() + return res, errors.CatchCCloudV2Error(err, httpResp) +} + +func (c *Client) TriggerSwitchoverPairFailover(id string, req switchoverv1.SwitchoverV1SwitchoverPairFailoverRequest) (switchoverv1.SwitchoverV1SwitchoverPair, error) { + res, httpResp, err := c.SwitchoverClient.SwitchoverPairsSwitchoverV1Api.FailoverSwitchoverV1SwitchoverPair(c.switchoverApiContext(), id).SwitchoverV1SwitchoverPairFailoverRequest(req).Execute() + return res, errors.CatchCCloudV2Error(err, httpResp) +} + +func (c *Client) ListSwitchoverPairs(environment string) ([]switchoverv1.SwitchoverV1SwitchoverPair, error) { + var list []switchoverv1.SwitchoverV1SwitchoverPair + + done := false + pageToken := "" + for !done { + page, httpResp, err := c.executeListSwitchoverPairs(environment, pageToken) + if err != nil { + return nil, errors.CatchCCloudV2Error(err, httpResp) + } + list = append(list, page.GetData()...) + + metadata := page.GetMetadata() + pagination := metadata.GetPagination() + pageToken = pagination.GetNextPageToken() + done = pageToken == "" + } + + return list, nil +} + +func (c *Client) executeListSwitchoverPairs(environment, pageToken string) (switchoverv1.SwitchoverV1SwitchoverPairList, *http.Response, error) { + req := c.SwitchoverClient.SwitchoverPairsSwitchoverV1Api.ListSwitchoverV1SwitchoverPairs(c.switchoverApiContext()).Environment(environment).PageSize(ccloudV2ListPageSize) + if pageToken != "" { + req = req.PageToken(pageToken) + } + return req.Execute() +} + +func (c *Client) DeleteSwitchoverPair(id, environment string) error { + httpResp, err := c.SwitchoverClient.SwitchoverPairsSwitchoverV1Api.DeleteSwitchoverV1SwitchoverPair(c.switchoverApiContext(), id).Environment(environment).Execute() + return errors.CatchCCloudV2Error(err, httpResp) +} + +func (c *Client) CreateSwitchoverEndpoint(endpoint switchoverv1.SwitchoverV1SwitchoverEndpoint) (switchoverv1.SwitchoverV1SwitchoverEndpoint, error) { + res, httpResp, err := c.SwitchoverClient.SwitchoverEndpointsSwitchoverV1Api.CreateSwitchoverV1SwitchoverEndpoint(c.switchoverApiContext()).SwitchoverV1SwitchoverEndpoint(endpoint).Execute() + return res, errors.CatchCCloudV2Error(err, httpResp) +} + +func (c *Client) GetSwitchoverEndpoint(id, environment string) (switchoverv1.SwitchoverV1SwitchoverEndpoint, error) { + res, httpResp, err := c.SwitchoverClient.SwitchoverEndpointsSwitchoverV1Api.GetSwitchoverV1SwitchoverEndpoint(c.switchoverApiContext(), id).Environment(environment).Execute() + return res, errors.CatchCCloudV2Error(err, httpResp) +} + +func (c *Client) UpdateSwitchoverEndpoint(id, environment string, update switchoverv1.SwitchoverV1SwitchoverEndpointUpdateRequest) (switchoverv1.SwitchoverV1SwitchoverEndpoint, error) { + res, httpResp, err := c.SwitchoverClient.SwitchoverEndpointsSwitchoverV1Api.UpdateSwitchoverV1SwitchoverEndpoint(c.switchoverApiContext(), id).Environment(environment).SwitchoverV1SwitchoverEndpointUpdateRequest(update).Execute() + return res, errors.CatchCCloudV2Error(err, httpResp) +} + +func (c *Client) ListSwitchoverEndpoints(environment, switchoverPair string) ([]switchoverv1.SwitchoverV1SwitchoverEndpoint, error) { + var list []switchoverv1.SwitchoverV1SwitchoverEndpoint + + done := false + pageToken := "" + for !done { + page, httpResp, err := c.executeListSwitchoverEndpoints(environment, switchoverPair, pageToken) + if err != nil { + return nil, errors.CatchCCloudV2Error(err, httpResp) + } + list = append(list, page.GetData()...) + + metadata := page.GetMetadata() + pagination := metadata.GetPagination() + pageToken = pagination.GetNextPageToken() + done = pageToken == "" + } + + return list, nil +} + +func (c *Client) executeListSwitchoverEndpoints(environment, switchoverPair, pageToken string) (switchoverv1.SwitchoverV1SwitchoverEndpointList, *http.Response, error) { + req := c.SwitchoverClient.SwitchoverEndpointsSwitchoverV1Api.ListSwitchoverV1SwitchoverEndpoints(c.switchoverApiContext()).Environment(environment).PageSize(ccloudV2ListPageSize) + if switchoverPair != "" { + req = req.SwitchoverPair(switchoverPair) + } + if pageToken != "" { + req = req.PageToken(pageToken) + } + return req.Execute() +} + +func (c *Client) DeleteSwitchoverEndpoint(id, environment string) error { + httpResp, err := c.SwitchoverClient.SwitchoverEndpointsSwitchoverV1Api.DeleteSwitchoverV1SwitchoverEndpoint(c.switchoverApiContext(), id).Environment(environment).Execute() + return errors.CatchCCloudV2Error(err, httpResp) +} diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 8cd05b3df7..353bc88b4c 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -81,6 +81,8 @@ const ( SchemaRegistryConfiguration = "Schema Registry configuration" ServiceAccount = "service account" SsoGroupMapping = "SSO group mapping" + SwitchoverPair = "switchover pair" + SwitchoverEndpoint = "switchover endpoint" Tableflow = "tableflow" Topic = "topic" TransitGatewayAttachment = "transit gateway attachment"