diff --git a/droplet_actions.go b/droplet_actions.go index 2e09d0c5..ed0f583c 100644 --- a/droplet_actions.go +++ b/droplet_actions.go @@ -30,6 +30,8 @@ type DropletActionsService interface { SnapshotByTag(context.Context, string, string) ([]Action, *Response, error) EnableBackups(context.Context, int) (*Action, *Response, error) EnableBackupsByTag(context.Context, string) ([]Action, *Response, error) + EnableBackupsWithPolicy(context.Context, int, *DropletBackupPolicyRequest) (*Action, *Response, error) + ChangeBackupPolicy(context.Context, int, *DropletBackupPolicyRequest) (*Action, *Response, error) DisableBackups(context.Context, int) (*Action, *Response, error) DisableBackupsByTag(context.Context, string) ([]Action, *Response, error) PasswordReset(context.Context, int) (*Action, *Response, error) @@ -169,6 +171,42 @@ func (s *DropletActionsServiceOp) EnableBackupsByTag(ctx context.Context, tag st return s.doActionByTag(ctx, tag, request) } +// EnableBackupsWithPolicy enables droplet's backup with a backup policy applied. +func (s *DropletActionsServiceOp) EnableBackupsWithPolicy(ctx context.Context, id int, policy *DropletBackupPolicyRequest) (*Action, *Response, error) { + if policy == nil { + return nil, nil, NewArgError("policy", "policy can't be nil") + } + + policyMap := map[string]interface{}{ + "plan": policy.Plan, + "weekday": policy.Weekday, + } + if policy.Hour != nil { + policyMap["hour"] = policy.Hour + } + + request := &ActionRequest{"type": "enable_backups", "backup_policy": policyMap} + return s.doAction(ctx, id, request) +} + +// ChangeBackupPolicy updates a backup policy when backups are enabled. +func (s *DropletActionsServiceOp) ChangeBackupPolicy(ctx context.Context, id int, policy *DropletBackupPolicyRequest) (*Action, *Response, error) { + if policy == nil { + return nil, nil, NewArgError("policy", "policy can't be nil") + } + + policyMap := map[string]interface{}{ + "plan": policy.Plan, + "weekday": policy.Weekday, + } + if policy.Hour != nil { + policyMap["hour"] = policy.Hour + } + + request := &ActionRequest{"type": "change_backup_policy", "backup_policy": policyMap} + return s.doAction(ctx, id, request) +} + // DisableBackups disables backups for a Droplet. func (s *DropletActionsServiceOp) DisableBackups(ctx context.Context, id int) (*Action, *Response, error) { request := &ActionRequest{"type": "disable_backups"} diff --git a/droplet_actions_test.go b/droplet_actions_test.go index 4beb06fc..e41651f4 100644 --- a/droplet_actions_test.go +++ b/droplet_actions_test.go @@ -592,6 +592,102 @@ func TestDropletAction_EnableBackupsByTag(t *testing.T) { } } +func TestDropletAction_EnableBackupsWithPolicy(t *testing.T) { + setup() + defer teardown() + + policyRequest := &DropletBackupPolicyRequest{ + Plan: "weekly", + Weekday: "TUE", + Hour: PtrTo(20), + } + + policy := map[string]interface{}{ + "hour": float64(20), + "plan": "weekly", + "weekday": "TUE", + } + + request := &ActionRequest{ + "type": "enable_backups", + "backup_policy": policy, + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + t.Fatalf("decode json: %v", err) + } + + testMethod(t, r, http.MethodPost) + + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + action, _, err := client.DropletActions.EnableBackupsWithPolicy(ctx, 1, policyRequest) + if err != nil { + t.Errorf("DropletActions.EnableBackups returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.EnableBackups returned %+v, expected %+v", action, expected) + } +} + +func TestDropletAction_ChangeBackupPolicy(t *testing.T) { + setup() + defer teardown() + + policyRequest := &DropletBackupPolicyRequest{ + Plan: "weekly", + Weekday: "SUN", + Hour: PtrTo(0), + } + + policy := map[string]interface{}{ + "hour": float64(0), + "plan": "weekly", + "weekday": "SUN", + } + + request := &ActionRequest{ + "type": "change_backup_policy", + "backup_policy": policy, + } + + mux.HandleFunc("/v2/droplets/1/actions", func(w http.ResponseWriter, r *http.Request) { + v := new(ActionRequest) + err := json.NewDecoder(r.Body).Decode(v) + if err != nil { + t.Fatalf("decode json: %v", err) + } + + testMethod(t, r, http.MethodPost) + + if !reflect.DeepEqual(v, request) { + t.Errorf("Request body = %+v, expected %+v", v, request) + } + + fmt.Fprintf(w, `{"action":{"status":"in-progress"}}`) + }) + + action, _, err := client.DropletActions.ChangeBackupPolicy(ctx, 1, policyRequest) + if err != nil { + t.Errorf("DropletActions.EnableBackups returned error: %v", err) + } + + expected := &Action{Status: "in-progress"} + if !reflect.DeepEqual(action, expected) { + t.Errorf("DropletActions.EnableBackups returned %+v, expected %+v", action, expected) + } +} + func TestDropletAction_DisableBackups(t *testing.T) { setup() defer teardown() diff --git a/droplets.go b/droplets.go index 1ed09ec8..2ddd7d6b 100644 --- a/droplets.go +++ b/droplets.go @@ -30,6 +30,9 @@ type DropletsService interface { Backups(context.Context, int, *ListOptions) ([]Image, *Response, error) Actions(context.Context, int, *ListOptions) ([]Action, *Response, error) Neighbors(context.Context, int) ([]Droplet, *Response, error) + GetBackupPolicy(context.Context, int) (*DropletBackupPolicy, *Response, error) + ListBackupPolicies(context.Context, *ListOptions) (map[int]*DropletBackupPolicy, *Response, error) + ListSupportedBackupPolicies(context.Context) ([]*SupportedBackupPolicy, *Response, error) } // DropletsServiceOp handles communication with the Droplet related methods of the @@ -218,37 +221,46 @@ func (d DropletCreateSSHKey) MarshalJSON() ([]byte, error) { // DropletCreateRequest represents a request to create a Droplet. type DropletCreateRequest struct { - Name string `json:"name"` - Region string `json:"region"` - Size string `json:"size"` - Image DropletCreateImage `json:"image"` - SSHKeys []DropletCreateSSHKey `json:"ssh_keys"` - Backups bool `json:"backups"` - IPv6 bool `json:"ipv6"` - PrivateNetworking bool `json:"private_networking"` - Monitoring bool `json:"monitoring"` - UserData string `json:"user_data,omitempty"` - Volumes []DropletCreateVolume `json:"volumes,omitempty"` - Tags []string `json:"tags"` - VPCUUID string `json:"vpc_uuid,omitempty"` - WithDropletAgent *bool `json:"with_droplet_agent,omitempty"` + Name string `json:"name"` + Region string `json:"region"` + Size string `json:"size"` + Image DropletCreateImage `json:"image"` + SSHKeys []DropletCreateSSHKey `json:"ssh_keys"` + Backups bool `json:"backups"` + IPv6 bool `json:"ipv6"` + PrivateNetworking bool `json:"private_networking"` + Monitoring bool `json:"monitoring"` + UserData string `json:"user_data,omitempty"` + Volumes []DropletCreateVolume `json:"volumes,omitempty"` + Tags []string `json:"tags"` + VPCUUID string `json:"vpc_uuid,omitempty"` + WithDropletAgent *bool `json:"with_droplet_agent,omitempty"` + BackupPolicy *DropletBackupPolicyRequest `json:"backup_policy,omitempty"` } // DropletMultiCreateRequest is a request to create multiple Droplets. type DropletMultiCreateRequest struct { - Names []string `json:"names"` - Region string `json:"region"` - Size string `json:"size"` - Image DropletCreateImage `json:"image"` - SSHKeys []DropletCreateSSHKey `json:"ssh_keys"` - Backups bool `json:"backups"` - IPv6 bool `json:"ipv6"` - PrivateNetworking bool `json:"private_networking"` - Monitoring bool `json:"monitoring"` - UserData string `json:"user_data,omitempty"` - Tags []string `json:"tags"` - VPCUUID string `json:"vpc_uuid,omitempty"` - WithDropletAgent *bool `json:"with_droplet_agent,omitempty"` + Names []string `json:"names"` + Region string `json:"region"` + Size string `json:"size"` + Image DropletCreateImage `json:"image"` + SSHKeys []DropletCreateSSHKey `json:"ssh_keys"` + Backups bool `json:"backups"` + IPv6 bool `json:"ipv6"` + PrivateNetworking bool `json:"private_networking"` + Monitoring bool `json:"monitoring"` + UserData string `json:"user_data,omitempty"` + Tags []string `json:"tags"` + VPCUUID string `json:"vpc_uuid,omitempty"` + WithDropletAgent *bool `json:"with_droplet_agent,omitempty"` + BackupPolicy *DropletBackupPolicyRequest `json:"backup_policy,omitempty"` +} + +// DropletBackupPolicyRequest defines the backup policy when creating a Droplet. +type DropletBackupPolicyRequest struct { + Plan string `json:"plan,omitempty"` + Weekday string `json:"weekday,omitempty"` + Hour *int `json:"hour,omitempty"` } func (d DropletCreateRequest) String() string { @@ -618,3 +630,109 @@ func (s *DropletsServiceOp) dropletActionStatus(ctx context.Context, uri string) return action.Status, nil } + +// DropletBackupPolicy defines the information about a droplet's backup policy. +type DropletBackupPolicy struct { + DropletID int `json:"droplet_id,omitempty"` + BackupEnabled bool `json:"backup_enabled,omitempty"` + BackupPolicy *DropletBackupPolicyConfig `json:"backup_policy,omitempty"` + NextBackupWindow *BackupWindow `json:"next_backup_window,omitempty"` +} + +// DropletBackupPolicyConfig defines the backup policy for a Droplet. +type DropletBackupPolicyConfig struct { + Plan string `json:"plan,omitempty"` + Weekday string `json:"weekday,omitempty"` + Hour int `json:"hour,omitempty"` + WindowLengthHours int `json:"window_length_hours,omitempty"` + RetentionPeriodDays int `json:"retention_period_days,omitempty"` +} + +// dropletBackupPolicyRoot represents a DropletBackupPolicy root +type dropletBackupPolicyRoot struct { + DropletBackupPolicy *DropletBackupPolicy `json:"policy,omitempty"` +} + +type dropletBackupPoliciesRoot struct { + DropletBackupPolicies map[int]*DropletBackupPolicy `json:"policies,omitempty"` + Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta"` +} + +// Get individual droplet backup policy. +func (s *DropletsServiceOp) GetBackupPolicy(ctx context.Context, dropletID int) (*DropletBackupPolicy, *Response, error) { + if dropletID < 1 { + return nil, nil, NewArgError("dropletID", "cannot be less than 1") + } + + path := fmt.Sprintf("%s/%d/backups/policy", dropletBasePath, dropletID) + + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(dropletBackupPolicyRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.DropletBackupPolicy, resp, err +} + +// List all droplet backup policies. +func (s *DropletsServiceOp) ListBackupPolicies(ctx context.Context, opt *ListOptions) (map[int]*DropletBackupPolicy, *Response, error) { + path := fmt.Sprintf("%s/backups/policies", dropletBasePath) + path, err := addOptions(path, opt) + if err != nil { + return nil, nil, err + } + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(dropletBackupPoliciesRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + if l := root.Links; l != nil { + resp.Links = l + } + if m := root.Meta; m != nil { + resp.Meta = m + } + + return root.DropletBackupPolicies, resp, nil +} + +type SupportedBackupPolicy struct { + Name string `json:"name,omitempty"` + PossibleWindowStarts []int `json:"possible_window_starts,omitempty"` + WindowLengthHours int `json:"window_length_hours,omitempty"` + RetentionPeriodDays int `json:"retention_period_days,omitempty"` + PossibleDays []string `json:"possible_days,omitempty"` +} + +type dropletSupportedBackupPoliciesRoot struct { + SupportedBackupPolicies []*SupportedBackupPolicy `json:"supported_policies,omitempty"` +} + +// List supported droplet backup policies. +func (s *DropletsServiceOp) ListSupportedBackupPolicies(ctx context.Context) ([]*SupportedBackupPolicy, *Response, error) { + path := fmt.Sprintf("%s/backups/supported_policies", dropletBasePath) + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + + root := new(dropletSupportedBackupPoliciesRoot) + resp, err := s.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + return root.SupportedBackupPolicies, resp, nil +} diff --git a/droplets_test.go b/droplets_test.go index ddd23f9e..2b510ac8 100644 --- a/droplets_test.go +++ b/droplets_test.go @@ -1,11 +1,16 @@ package godo import ( + "context" "encoding/json" "fmt" "net/http" "reflect" "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDroplets_ListDroplets(t *testing.T) { @@ -316,6 +321,12 @@ func TestDroplets_Create(t *testing.T) { }, Tags: []string{"one", "two"}, VPCUUID: "880b7f98-f062-404d-b33c-458d545696f6", + Backups: true, + BackupPolicy: &DropletBackupPolicyRequest{ + Plan: "weekly", + Weekday: "MON", + Hour: PtrTo(0), + }, } mux.HandleFunc("/v2/droplets", func(w http.ResponseWriter, r *http.Request) { @@ -325,7 +336,6 @@ func TestDroplets_Create(t *testing.T) { "size": "size", "image": float64(1), "ssh_keys": nil, - "backups": false, "ipv6": false, "private_networking": false, "monitoring": false, @@ -333,8 +343,10 @@ func TestDroplets_Create(t *testing.T) { map[string]interface{}{"id": "hello-im-another-volume"}, map[string]interface{}{"id": "aaa-111-bbb-222-ccc"}, }, - "tags": []interface{}{"one", "two"}, - "vpc_uuid": "880b7f98-f062-404d-b33c-458d545696f6", + "tags": []interface{}{"one", "two"}, + "vpc_uuid": "880b7f98-f062-404d-b33c-458d545696f6", + "backups": true, + "backup_policy": map[string]interface{}{"plan": "weekly", "weekday": "MON", "hour": float64(0)}, } jsonBlob := ` { @@ -947,3 +959,123 @@ func TestDroplets_IPMethods(t *testing.T) { t.Errorf("Droplet.PublicIPv6 returned %s; expected %s", got, expected) } } + +func TestDroplets_GetBackupPolicy(t *testing.T) { + setup() + defer teardown() + + mux.HandleFunc("/v2/droplets/12345/backups/policy", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + fmt.Fprint(w, `{ + "policy": { + "droplet_id": 12345, + "backup_enabled": true, + "backup_policy": { + "plan": "weekly", + "weekday": "SUN", + "hour": 0, + "window_length_hours": 4, + "retention_period_days": 28 + }, + "next_backup_window": { + "start": "2021-01-01T00:00:00Z", + "end": "2021-01-01T00:00:00Z" + } + } + }`) + }) + + policy, _, err := client.Droplets.GetBackupPolicy(ctx, 12345) + if err != nil { + t.Errorf("Droplets.GetBackupPolicy returned error: %v", err) + } + + pt, err := time.Parse(time.RFC3339, "2021-01-01T00:00:00Z") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + expected := &DropletBackupPolicy{ + DropletID: 12345, + BackupEnabled: true, + BackupPolicy: &DropletBackupPolicyConfig{ + Plan: "weekly", + Weekday: "SUN", + Hour: 0, + WindowLengthHours: 4, + RetentionPeriodDays: 28, + }, + NextBackupWindow: &BackupWindow{ + Start: &Timestamp{Time: pt}, + End: &Timestamp{Time: pt}, + }, + } + if !reflect.DeepEqual(policy, expected) { + t.Errorf("Droplets.GetBackupPolicy\n got=%#v\nwant=%#v", policy, expected) + } +} + +func TestDroplets_ListBackupPolicies(t *testing.T) { + setup() + defer teardown() + + ctx := context.Background() + policyID := 123 + pt, err := time.Parse(time.RFC3339, "2021-01-01T00:00:00Z") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + testBackupPolicy := DropletBackupPolicy{ + DropletID: 12345, + BackupEnabled: true, + BackupPolicy: &DropletBackupPolicyConfig{ + Plan: "weekly", + Weekday: "SUN", + Hour: 0, + WindowLengthHours: 4, + RetentionPeriodDays: 28, + }, + NextBackupWindow: &BackupWindow{ + Start: &Timestamp{Time: pt}, + End: &Timestamp{Time: pt}, + }, + } + + mux.HandleFunc("/v2/droplets/backups/policies", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + + json.NewEncoder(w).Encode(&dropletBackupPoliciesRoot{ + DropletBackupPolicies: map[int]*DropletBackupPolicy{policyID: &testBackupPolicy}, + Meta: &Meta{Total: 1}, + Links: &Links{}, + }) + }) + + policies, _, err := client.Droplets.ListBackupPolicies(ctx, &ListOptions{Page: 1}) + require.NoError(t, err) + assert.Equal(t, map[int]*DropletBackupPolicy{policyID: &testBackupPolicy}, policies) +} + +func TestDroplets_ListSupportedBackupPolicies(t *testing.T) { + setup() + defer teardown() + + ctx := context.Background() + testSupportedBackupPolicy := SupportedBackupPolicy{ + Name: "weekly", + PossibleWindowStarts: []int{0, 4, 8, 12, 16, 20}, + WindowLengthHours: 4, + RetentionPeriodDays: 28, + PossibleDays: []string{"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}, + } + + mux.HandleFunc("/v2/droplets/backups/supported_policies", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, http.MethodGet) + + json.NewEncoder(w).Encode(&dropletSupportedBackupPoliciesRoot{SupportedBackupPolicies: []*SupportedBackupPolicy{&testSupportedBackupPolicy}}) + }) + + policies, _, err := client.Droplets.ListSupportedBackupPolicies(ctx) + require.NoError(t, err) + assert.Equal(t, []*SupportedBackupPolicy{&testSupportedBackupPolicy}, policies) +}