2019-06-21 01:32:00 +00:00
|
|
|
package api
|
|
|
|
|
|
|
|
import (
|
2021-09-07 15:16:37 +00:00
|
|
|
"archive/tar"
|
|
|
|
"compress/gzip"
|
2019-06-21 01:32:00 +00:00
|
|
|
"context"
|
2021-03-03 18:59:50 +00:00
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
2019-06-21 01:32:00 +00:00
|
|
|
"io"
|
2021-09-07 15:16:37 +00:00
|
|
|
"io/ioutil"
|
2019-06-21 01:32:00 +00:00
|
|
|
"net/http"
|
2021-09-07 15:16:37 +00:00
|
|
|
"sync"
|
2021-03-03 18:59:50 +00:00
|
|
|
"time"
|
|
|
|
|
2021-07-16 00:17:31 +00:00
|
|
|
"github.com/hashicorp/go-secure-stdlib/parseutil"
|
2021-09-07 15:16:37 +00:00
|
|
|
"github.com/mitchellh/mapstructure"
|
2019-06-21 01:32:00 +00:00
|
|
|
)
|
|
|
|
|
2021-09-07 15:16:37 +00:00
|
|
|
var ErrIncompleteSnapshot = errors.New("incomplete snapshot, unable to read SHA256SUMS.sealed file")
|
|
|
|
|
2019-06-21 01:32:00 +00:00
|
|
|
// RaftJoinResponse represents the response of the raft join API
|
|
|
|
type RaftJoinResponse struct {
|
|
|
|
Joined bool `json:"joined"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// RaftJoinRequest represents the parameters consumed by the raft join API
|
|
|
|
type RaftJoinRequest struct {
|
2020-10-13 20:26:39 +00:00
|
|
|
AutoJoin string `json:"auto_join"`
|
2020-10-23 20:13:09 +00:00
|
|
|
AutoJoinScheme string `json:"auto_join_scheme"`
|
|
|
|
AutoJoinPort uint `json:"auto_join_port"`
|
2019-06-21 21:41:07 +00:00
|
|
|
LeaderAPIAddr string `json:"leader_api_addr"`
|
2019-11-05 17:07:06 +00:00
|
|
|
LeaderCACert string `json:"leader_ca_cert"`
|
2019-06-21 21:41:07 +00:00
|
|
|
LeaderClientCert string `json:"leader_client_cert"`
|
|
|
|
LeaderClientKey string `json:"leader_client_key"`
|
|
|
|
Retry bool `json:"retry"`
|
2021-02-10 21:41:58 +00:00
|
|
|
NonVoter bool `json:"non_voter"`
|
2019-06-21 01:32:00 +00:00
|
|
|
}
|
|
|
|
|
2021-03-03 18:59:50 +00:00
|
|
|
// AutopilotConfig is used for querying/setting the Autopilot configuration.
|
|
|
|
type AutopilotConfig struct {
|
|
|
|
CleanupDeadServers bool `json:"cleanup_dead_servers" mapstructure:"cleanup_dead_servers"`
|
|
|
|
LastContactThreshold time.Duration `json:"last_contact_threshold" mapstructure:"-"`
|
|
|
|
DeadServerLastContactThreshold time.Duration `json:"dead_server_last_contact_threshold" mapstructure:"-"`
|
|
|
|
MaxTrailingLogs uint64 `json:"max_trailing_logs" mapstructure:"max_trailing_logs"`
|
|
|
|
MinQuorum uint `json:"min_quorum" mapstructure:"min_quorum"`
|
|
|
|
ServerStabilizationTime time.Duration `json:"server_stabilization_time" mapstructure:"-"`
|
2022-05-20 20:49:11 +00:00
|
|
|
DisableUpgradeMigration bool `json:"disable_upgrade_migration" mapstructure:"disable_upgrade_migration"`
|
2021-03-03 18:59:50 +00:00
|
|
|
}
|
|
|
|
|
2021-03-22 14:23:12 +00:00
|
|
|
// MarshalJSON makes the autopilot config fields JSON compatible
|
|
|
|
func (ac *AutopilotConfig) MarshalJSON() ([]byte, error) {
|
|
|
|
return json.Marshal(map[string]interface{}{
|
|
|
|
"cleanup_dead_servers": ac.CleanupDeadServers,
|
|
|
|
"last_contact_threshold": ac.LastContactThreshold.String(),
|
|
|
|
"dead_server_last_contact_threshold": ac.DeadServerLastContactThreshold.String(),
|
|
|
|
"max_trailing_logs": ac.MaxTrailingLogs,
|
|
|
|
"min_quorum": ac.MinQuorum,
|
|
|
|
"server_stabilization_time": ac.ServerStabilizationTime.String(),
|
2022-05-20 20:49:11 +00:00
|
|
|
"disable_upgrade_migration": ac.DisableUpgradeMigration,
|
2021-03-22 14:23:12 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2021-03-03 18:59:50 +00:00
|
|
|
// UnmarshalJSON parses the autopilot config JSON blob
|
|
|
|
func (ac *AutopilotConfig) UnmarshalJSON(b []byte) error {
|
|
|
|
var data interface{}
|
|
|
|
err := json.Unmarshal(b, &data)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
conf := data.(map[string]interface{})
|
|
|
|
if err = mapstructure.WeakDecode(conf, ac); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if ac.LastContactThreshold, err = parseutil.ParseDurationSecond(conf["last_contact_threshold"]); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if ac.DeadServerLastContactThreshold, err = parseutil.ParseDurationSecond(conf["dead_server_last_contact_threshold"]); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if ac.ServerStabilizationTime, err = parseutil.ParseDurationSecond(conf["server_stabilization_time"]); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// AutopilotState represents the response of the raft autopilot state API
|
|
|
|
type AutopilotState struct {
|
2022-05-20 20:49:11 +00:00
|
|
|
Healthy bool `mapstructure:"healthy"`
|
|
|
|
FailureTolerance int `mapstructure:"failure_tolerance"`
|
|
|
|
Servers map[string]*AutopilotServer `mapstructure:"servers"`
|
|
|
|
Leader string `mapstructure:"leader"`
|
|
|
|
Voters []string `mapstructure:"voters"`
|
|
|
|
NonVoters []string `mapstructure:"non_voters"`
|
|
|
|
RedundancyZones map[string]AutopilotZone `mapstructure:"redundancy_zones,omitempty"`
|
|
|
|
Upgrade *AutopilotUpgrade `mapstructure:"upgrade_info,omitempty"`
|
|
|
|
OptimisticFailureTolerance int `mapstructure:"optimistic_failure_tolerance,omitempty"`
|
2021-03-03 18:59:50 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// AutopilotServer represents the server blocks in the response of the raft
|
|
|
|
// autopilot state API.
|
|
|
|
type AutopilotServer struct {
|
2022-05-20 20:49:11 +00:00
|
|
|
ID string `mapstructure:"id"`
|
|
|
|
Name string `mapstructure:"name"`
|
|
|
|
Address string `mapstructure:"address"`
|
|
|
|
NodeStatus string `mapstructure:"node_status"`
|
|
|
|
LastContact string `mapstructure:"last_contact"`
|
|
|
|
LastTerm uint64 `mapstructure:"last_term"`
|
|
|
|
LastIndex uint64 `mapstructure:"last_index"`
|
|
|
|
Healthy bool `mapstructure:"healthy"`
|
|
|
|
StableSince string `mapstructure:"stable_since"`
|
|
|
|
Status string `mapstructure:"status"`
|
|
|
|
Version string `mapstructure:"version"`
|
|
|
|
UpgradeVersion string `mapstructure:"upgrade_version,omitempty"`
|
|
|
|
RedundancyZone string `mapstructure:"redundancy_zone,omitempty"`
|
|
|
|
NodeType string `mapstructure:"node_type,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type AutopilotZone struct {
|
|
|
|
Servers []string `mapstructure:"servers,omitempty"`
|
|
|
|
Voters []string `mapstructure:"voters,omitempty"`
|
|
|
|
FailureTolerance int `mapstructure:"failure_tolerance,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type AutopilotUpgrade struct {
|
|
|
|
Status string `mapstructure:"status"`
|
|
|
|
TargetVersion string `mapstructure:"target_version,omitempty"`
|
|
|
|
TargetVersionVoters []string `mapstructure:"target_version_voters,omitempty"`
|
|
|
|
TargetVersionNonVoters []string `mapstructure:"target_version_non_voters,omitempty"`
|
|
|
|
TargetVersionReadReplicas []string `mapstructure:"target_version_read_replicas,omitempty"`
|
|
|
|
OtherVersionVoters []string `mapstructure:"other_version_voters,omitempty"`
|
|
|
|
OtherVersionNonVoters []string `mapstructure:"other_version_non_voters,omitempty"`
|
|
|
|
OtherVersionReadReplicas []string `mapstructure:"other_version_read_replicas,omitempty"`
|
|
|
|
RedundancyZones map[string]AutopilotZoneUpgradeVersions `mapstructure:"redundancy_zones,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type AutopilotZoneUpgradeVersions struct {
|
|
|
|
TargetVersionVoters []string `mapstructure:"target_version_voters,omitempty"`
|
|
|
|
TargetVersionNonVoters []string `mapstructure:"target_version_non_voters,omitempty"`
|
|
|
|
OtherVersionVoters []string `mapstructure:"other_version_voters,omitempty"`
|
|
|
|
OtherVersionNonVoters []string `mapstructure:"other_version_non_voters,omitempty"`
|
2021-03-03 18:59:50 +00:00
|
|
|
}
|
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
// RaftJoin wraps RaftJoinWithContext using context.Background.
|
2019-06-21 01:32:00 +00:00
|
|
|
func (c *Sys) RaftJoin(opts *RaftJoinRequest) (*RaftJoinResponse, error) {
|
2022-03-23 21:47:43 +00:00
|
|
|
return c.RaftJoinWithContext(context.Background(), opts)
|
|
|
|
}
|
|
|
|
|
|
|
|
// RaftJoinWithContext adds the node from which this call is invoked from to the raft
|
|
|
|
// cluster represented by the leader address in the parameter.
|
|
|
|
func (c *Sys) RaftJoinWithContext(ctx context.Context, opts *RaftJoinRequest) (*RaftJoinResponse, error) {
|
|
|
|
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
|
|
|
|
defer cancelFunc()
|
|
|
|
|
2022-03-24 17:58:03 +00:00
|
|
|
r := c.c.NewRequest(http.MethodPost, "/v1/sys/storage/raft/join")
|
2019-06-21 01:32:00 +00:00
|
|
|
|
|
|
|
if err := r.SetJSONBody(opts); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
resp, err := c.c.rawRequestWithContext(ctx, r)
|
2019-06-21 01:32:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
var result RaftJoinResponse
|
|
|
|
err = resp.DecodeJSON(&result)
|
|
|
|
return &result, err
|
|
|
|
}
|
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
// RaftSnapshot wraps RaftSnapshotWithContext using context.Background.
|
2019-06-21 01:32:00 +00:00
|
|
|
func (c *Sys) RaftSnapshot(snapWriter io.Writer) error {
|
2022-03-23 21:47:43 +00:00
|
|
|
return c.RaftSnapshotWithContext(context.Background(), snapWriter)
|
2022-03-14 17:13:33 +00:00
|
|
|
}
|
2019-06-21 01:32:00 +00:00
|
|
|
|
2022-03-14 17:13:33 +00:00
|
|
|
// RaftSnapshotWithContext invokes the API that takes the snapshot of the raft cluster and
|
|
|
|
// writes it to the supplied io.Writer.
|
|
|
|
func (c *Sys) RaftSnapshotWithContext(ctx context.Context, snapWriter io.Writer) error {
|
2022-03-24 17:58:03 +00:00
|
|
|
r := c.c.NewRequest(http.MethodGet, "/v1/sys/storage/raft/snapshot")
|
2022-03-14 17:13:33 +00:00
|
|
|
r.URL.RawQuery = r.Params.Encode()
|
2019-06-21 01:32:00 +00:00
|
|
|
|
2022-03-14 17:13:33 +00:00
|
|
|
resp, err := c.c.httpRequestWithContext(ctx, r)
|
2020-04-27 18:39:06 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-03-14 17:13:33 +00:00
|
|
|
defer resp.Body.Close()
|
2019-06-21 01:32:00 +00:00
|
|
|
|
2021-09-07 15:16:37 +00:00
|
|
|
// Make sure that the last file in the archive, SHA256SUMS.sealed, is present
|
|
|
|
// and non-empty. This is to catch cases where the snapshot failed midstream,
|
|
|
|
// e.g. due to a problem with the seal that prevented encryption of that file.
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
wg.Add(1)
|
|
|
|
var verified bool
|
|
|
|
|
|
|
|
rPipe, wPipe := io.Pipe()
|
|
|
|
dup := io.TeeReader(resp.Body, wPipe)
|
|
|
|
go func() {
|
|
|
|
defer func() {
|
|
|
|
io.Copy(ioutil.Discard, rPipe)
|
|
|
|
rPipe.Close()
|
|
|
|
wg.Done()
|
|
|
|
}()
|
|
|
|
|
|
|
|
uncompressed, err := gzip.NewReader(rPipe)
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
t := tar.NewReader(uncompressed)
|
|
|
|
var h *tar.Header
|
|
|
|
for {
|
|
|
|
h, err = t.Next()
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if h.Name != "SHA256SUMS.sealed" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
var b []byte
|
|
|
|
b, err = ioutil.ReadAll(t)
|
|
|
|
if err != nil || len(b) == 0 {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
verified = true
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
// Copy bytes from dup to snapWriter. This will have a side effect that
|
|
|
|
// everything read from dup will be written to wPipe.
|
|
|
|
_, err = io.Copy(snapWriter, dup)
|
|
|
|
wPipe.Close()
|
2019-06-21 01:32:00 +00:00
|
|
|
if err != nil {
|
2021-09-07 15:16:37 +00:00
|
|
|
rPipe.CloseWithError(err)
|
2019-06-21 01:32:00 +00:00
|
|
|
return err
|
|
|
|
}
|
2021-09-07 15:16:37 +00:00
|
|
|
wg.Wait()
|
2019-06-21 01:32:00 +00:00
|
|
|
|
2021-09-07 15:16:37 +00:00
|
|
|
if !verified {
|
|
|
|
return ErrIncompleteSnapshot
|
|
|
|
}
|
2019-06-21 01:32:00 +00:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
// RaftSnapshotRestore wraps RaftSnapshotRestoreWithContext using context.Background.
|
2019-06-21 01:32:00 +00:00
|
|
|
func (c *Sys) RaftSnapshotRestore(snapReader io.Reader, force bool) error {
|
2022-03-23 21:47:43 +00:00
|
|
|
return c.RaftSnapshotRestoreWithContext(context.Background(), snapReader, force)
|
2022-03-14 17:13:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// RaftSnapshotRestoreWithContext reads the snapshot from the io.Reader and installs that
|
|
|
|
// snapshot, returning the cluster to the state defined by it.
|
|
|
|
func (c *Sys) RaftSnapshotRestoreWithContext(ctx context.Context, snapReader io.Reader, force bool) error {
|
2019-06-21 01:32:00 +00:00
|
|
|
path := "/v1/sys/storage/raft/snapshot"
|
|
|
|
if force {
|
|
|
|
path = "/v1/sys/storage/raft/snapshot-force"
|
|
|
|
}
|
|
|
|
|
2022-03-14 17:13:33 +00:00
|
|
|
r := c.c.NewRequest(http.MethodPost, path)
|
2019-06-21 01:32:00 +00:00
|
|
|
r.Body = snapReader
|
|
|
|
|
2022-03-14 17:13:33 +00:00
|
|
|
resp, err := c.c.httpRequestWithContext(ctx, r)
|
2019-06-21 01:32:00 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
2021-03-03 18:59:50 +00:00
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
// RaftAutopilotState wraps RaftAutopilotStateWithContext using context.Background.
|
2021-03-03 18:59:50 +00:00
|
|
|
func (c *Sys) RaftAutopilotState() (*AutopilotState, error) {
|
2022-03-23 21:47:43 +00:00
|
|
|
return c.RaftAutopilotStateWithContext(context.Background())
|
|
|
|
}
|
2021-03-03 18:59:50 +00:00
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
// RaftAutopilotStateWithContext returns the state of the raft cluster as seen by autopilot.
|
|
|
|
func (c *Sys) RaftAutopilotStateWithContext(ctx context.Context) (*AutopilotState, error) {
|
|
|
|
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
|
2021-03-03 18:59:50 +00:00
|
|
|
defer cancelFunc()
|
2022-03-23 21:47:43 +00:00
|
|
|
|
2022-03-24 17:58:03 +00:00
|
|
|
r := c.c.NewRequest(http.MethodGet, "/v1/sys/storage/raft/autopilot/state")
|
2022-03-23 21:47:43 +00:00
|
|
|
|
|
|
|
resp, err := c.c.rawRequestWithContext(ctx, r)
|
2021-03-03 18:59:50 +00:00
|
|
|
if resp != nil {
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 404 {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
secret, err := ParseSecret(resp.Body)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if secret == nil || secret.Data == nil {
|
|
|
|
return nil, errors.New("data from server response is empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
var result AutopilotState
|
|
|
|
err = mapstructure.Decode(secret.Data, &result)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &result, err
|
|
|
|
}
|
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
// RaftAutopilotConfiguration wraps RaftAutopilotConfigurationWithContext using context.Background.
|
2021-03-03 18:59:50 +00:00
|
|
|
func (c *Sys) RaftAutopilotConfiguration() (*AutopilotConfig, error) {
|
2022-03-23 21:47:43 +00:00
|
|
|
return c.RaftAutopilotConfigurationWithContext(context.Background())
|
|
|
|
}
|
2021-03-03 18:59:50 +00:00
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
// RaftAutopilotConfigurationWithContext fetches the autopilot config.
|
|
|
|
func (c *Sys) RaftAutopilotConfigurationWithContext(ctx context.Context) (*AutopilotConfig, error) {
|
|
|
|
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
|
2021-03-03 18:59:50 +00:00
|
|
|
defer cancelFunc()
|
2022-03-23 21:47:43 +00:00
|
|
|
|
2022-03-24 17:58:03 +00:00
|
|
|
r := c.c.NewRequest(http.MethodGet, "/v1/sys/storage/raft/autopilot/configuration")
|
2022-03-23 21:47:43 +00:00
|
|
|
|
|
|
|
resp, err := c.c.rawRequestWithContext(ctx, r)
|
2021-03-03 18:59:50 +00:00
|
|
|
if resp != nil {
|
|
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 404 {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
secret, err := ParseSecret(resp.Body)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if secret == nil {
|
|
|
|
return nil, errors.New("data from server response is empty")
|
|
|
|
}
|
|
|
|
|
|
|
|
var result AutopilotConfig
|
|
|
|
if err = mapstructure.Decode(secret.Data, &result); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if result.LastContactThreshold, err = parseutil.ParseDurationSecond(secret.Data["last_contact_threshold"]); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if result.DeadServerLastContactThreshold, err = parseutil.ParseDurationSecond(secret.Data["dead_server_last_contact_threshold"]); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if result.ServerStabilizationTime, err = parseutil.ParseDurationSecond(secret.Data["server_stabilization_time"]); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &result, err
|
|
|
|
}
|
2021-11-10 17:10:15 +00:00
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
// PutRaftAutopilotConfiguration wraps PutRaftAutopilotConfigurationWithContext using context.Background.
|
2021-11-10 17:10:15 +00:00
|
|
|
func (c *Sys) PutRaftAutopilotConfiguration(opts *AutopilotConfig) error {
|
2022-03-23 21:47:43 +00:00
|
|
|
return c.PutRaftAutopilotConfigurationWithContext(context.Background(), opts)
|
|
|
|
}
|
|
|
|
|
|
|
|
// PutRaftAutopilotConfigurationWithContext allows modifying the raft autopilot configuration
|
|
|
|
func (c *Sys) PutRaftAutopilotConfigurationWithContext(ctx context.Context, opts *AutopilotConfig) error {
|
|
|
|
ctx, cancelFunc := c.c.withConfiguredTimeout(ctx)
|
|
|
|
defer cancelFunc()
|
|
|
|
|
2022-03-24 17:58:03 +00:00
|
|
|
r := c.c.NewRequest(http.MethodPost, "/v1/sys/storage/raft/autopilot/configuration")
|
2021-11-10 17:10:15 +00:00
|
|
|
|
|
|
|
if err := r.SetJSONBody(opts); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2022-03-23 21:47:43 +00:00
|
|
|
resp, err := c.c.rawRequestWithContext(ctx, r)
|
2021-11-10 17:10:15 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|