diff --git a/apiclient/apiclient.go b/apiclient/apiclient.go index d77cb1e1..661d69b1 100644 --- a/apiclient/apiclient.go +++ b/apiclient/apiclient.go @@ -223,6 +223,8 @@ type Transport interface { SetCordoned(ctx context.Context, nodeID id.Node, params *SetCordonedRequestParams) error EvictReplica(ctx context.Context, namespaceID string, id string, deploymentID string) error + + AttemptPromotion(ctx context.Context, namespaceID string, id string, deploymentID string) error } // Client provides a collection of methods for consumers to interact with the diff --git a/apiclient/mock_transport_test.go b/apiclient/mock_transport_test.go index 30cba9ff..fb2ffef4 100644 --- a/apiclient/mock_transport_test.go +++ b/apiclient/mock_transport_test.go @@ -434,3 +434,7 @@ func (m *mockTransport) SetCordoned(ctx context.Context, nodeID id.Node, params func (m *mockTransport) EvictReplica(ctx context.Context, namespaceID string, id string, deploymentID string) error { return nil } + +func (m *mockTransport) AttemptPromotion(ctx context.Context, namespaceID string, id string, deploymentID string) error { + return nil +} diff --git a/apiclient/no_transport.go b/apiclient/no_transport.go index 0c9a88c5..83d510f1 100644 --- a/apiclient/no_transport.go +++ b/apiclient/no_transport.go @@ -172,3 +172,7 @@ func (t *noTransport) SetCordoned(ctx context.Context, nodeID id.Node, params *S func (t *noTransport) EvictReplica(ctx context.Context, namespaceID string, id string, deploymentID string) error { return ErrNoTransportConfigured } + +func (t *noTransport) AttemptPromotion(ctx context.Context, namespaceID string, id string, deploymentID string) error { + return ErrNoTransportConfigured +} diff --git a/apiclient/openapi/volume.go b/apiclient/openapi/volume.go index 32315f27..3d21cbac 100644 --- a/apiclient/openapi/volume.go +++ b/apiclient/openapi/volume.go @@ -627,7 +627,6 @@ func (o *OpenAPI) ResizeVolume( return o.codec.decodeVolume(model) } - func (o *OpenAPI) EvictReplica(ctx context.Context, namespaceID string, id string, deploymentID string) error { o.mu.RLock() defer o.mu.RUnlock() @@ -650,3 +649,26 @@ func (o *OpenAPI) EvictReplica(ctx context.Context, namespaceID string, id strin return nil } + +func (o *OpenAPI) AttemptPromotion(ctx context.Context, namespaceID string, id string, deploymentID string) error { + o.mu.RLock() + defer o.mu.RUnlock() + + resp, err := o.client.DefaultApi.AttemptPromotion( + ctx, + namespaceID, + id, + deploymentID, + ) + + if err != nil { + switch v := mapOpenAPIError(err, resp).(type) { + case notFoundError: + return apiclient.NewVolumeNotFoundError(id) + default: + return v + } + } + + return nil +} diff --git a/apiclient/reauth_transport.go b/apiclient/reauth_transport.go index 78a3ce98..fca4ebb7 100644 --- a/apiclient/reauth_transport.go +++ b/apiclient/reauth_transport.go @@ -527,6 +527,13 @@ func (tr *TransportWithReauth) EvictReplica(ctx context.Context, namespaceID str return tr.inner.EvictReplica(ctx, namespaceID, id, deploymentID) }) } + +func (tr *TransportWithReauth) AttemptPromotion(ctx context.Context, namespaceID string, id string, deploymentID string) error { + return tr.doWithReauth(ctx, func() error { + return tr.inner.AttemptPromotion(ctx, namespaceID, id, deploymentID) + }) +} + // DetachVolume wraps the inner transport's call with a reauthenticate and retry // upon encountering an authentication error. func (tr *TransportWithReauth) DetachVolume(ctx context.Context, namespaceID id.Namespace, volumeID id.Volume, params *DetachVolumeRequestParams) error { diff --git a/cmd/interfaces/interfaces.go b/cmd/interfaces/interfaces.go index 2e4c818a..bb70bc42 100644 --- a/cmd/interfaces/interfaces.go +++ b/cmd/interfaces/interfaces.go @@ -86,6 +86,7 @@ type Client interface { SetFailureModeIntent(ctx context.Context, nsID id.Namespace, volID id.Volume, intent string, params *apiclient.SetFailureModeRequestParams) (*model.Volume, error) SetFailureThreshold(ctx context.Context, nsID id.Namespace, volID id.Volume, threshold uint64, params *apiclient.SetFailureModeRequestParams) (*model.Volume, error) EvictReplica(ctx context.Context, namespaceID string, id string, deploymentID string) error + AttemptPromotion(ctx context.Context, namespaceID string, id string, deploymentID string) error } type Displayer interface { diff --git a/cmd/promote/promote.go b/cmd/promote/promote.go new file mode 100644 index 00000000..d8cf67a6 --- /dev/null +++ b/cmd/promote/promote.go @@ -0,0 +1,122 @@ +package promote + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/spf13/cobra" + + "code.storageos.net/storageos/c2-cli/cmd/argwrappers" + "code.storageos.net/storageos/c2-cli/cmd/interfaces" + "code.storageos.net/storageos/c2-cli/cmd/runwrappers" + "code.storageos.net/storageos/c2-cli/pkg/health" + "code.storageos.net/storageos/c2-cli/pkg/id" +) + +type promoteCommand struct { + config interfaces.ConfigProvider + client interfaces.Client + + cordonTargetNode bool +} + +// NewCommand configures the promote command +func NewCommand(client interfaces.Client, config interfaces.ConfigProvider) *cobra.Command { + c := &promoteCommand{ + config: config, + client: client, + } + + command := &cobra.Command{ + Hidden: true, + Use: "promote", + Short: "promotes a volume's replica deployment to a primary", + Example: ` + $ storageos promote my-namespace my-volume my-replica-id`, + Args: argwrappers.WrapInvalidArgsError(func(_ *cobra.Command, args []string) error { + if len(args) < 3 { + return errors.New("received too few args") + } + if len(args) > 3 { + return fmt.Errorf("received more args than expected: %v", args) + } + return nil + }), + RunE: func(cmd *cobra.Command, args []string) error { + run := runwrappers.Chain( + runwrappers.RunWithTimeout(config), + runwrappers.EnsureNamespaceSetWhenUseIDs(config), + runwrappers.AuthenticateClient(config, client), + )(c.runWithCtx) + + return run(context.Background(), cmd, args) + }, + SilenceUsage: true, + } + + return command +} + +func (c *promoteCommand) runWithCtx(ctx context.Context, cmd *cobra.Command, args []string) error { + useIDs, err := c.config.UseIDs() + if err != nil { + return err + } + + nsID := id.Namespace(args[0]) + vID := id.Volume(args[1]) + dID := id.Deployment(args[2]) + + // A deployment has no name, so an ID will always be provided + if !useIDs { + nn, err := c.client.GetNamespaceByName(ctx, string(nsID)) + if err != nil { + return err + } + nsID = nn.ID + + v, err := c.client.GetVolumeByName(ctx, nsID, string(vID)) + if err != nil { + return err + } + vID = v.ID + } + + vol, err := c.client.GetVolume(ctx, nsID, vID) + if err != nil { + return err + } + + // Do some basic sanity checking to ensure we're not about to destroy a volume + // Controlplane does the same sanity checking, but we might as well be safe + if vol.Master.Health != health.MasterOnline { + return fmt.Errorf("cannot promote deployment - master unhealthy: %v", vol.Master.Health.String()) + } + for _, replica := range vol.Replicas { + if replica.Health != health.ReplicaReady || replica.Promotable == false { + return fmt.Errorf("cannot promote deployment - replica unhealthy, id: %v health: %v, promotable: %v", replica.ID, replica.Health, replica.Promotable) + } + } + + err = c.client.AttemptPromotion(ctx, nsID.String(), vID.String(), dID.String()) + if err != nil { + return fmt.Errorf("failed promotion attempt: %w", err) + } + + for i := 0; i < 10; i++ { + time.Sleep(3 * time.Second) + vol, err := c.client.GetVolume(ctx, nsID, vID) + if err != nil { + fmt.Printf("failed getting vol: %v\n", err) + continue + } + + if vol.Master.ID == dID { + fmt.Println("replica promotion successful") + break + } + } + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 74e3595c..db1514c1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "code.storageos.net/storageos/c2-cli/cmd/evict" "code.storageos.net/storageos/c2-cli/cmd/get" "code.storageos.net/storageos/c2-cli/cmd/nfs" + "code.storageos.net/storageos/c2-cli/cmd/promote" "code.storageos.net/storageos/c2-cli/cmd/update" "code.storageos.net/storageos/c2-cli/config" ) @@ -122,6 +123,7 @@ To be notified about stable releases and latest features, sign up at https://my. cordon.NewUncordonCommand(os.Stdout, client, config), versionCommand, evict.NewCommand(client, config), + promote.NewCommand(client, config), ) // Cobra subcommands which are not runnable and do not themselves have