diff --git a/args.go b/args.go index c651f3394..0accf8fad 100644 --- a/args.go +++ b/args.go @@ -168,6 +168,12 @@ const ( ArgResourceType = "resource" // ArgBackups is an enable backups argument. ArgBackups = "enable-backups" + // ArgDropletBackupPolicyPlan sets a frequency plan for backups. + ArgDropletBackupPolicyPlan = "backup-policy-plan" + // ArgDropletBackupPolicyWeekday sets backup policy day of the week. + ArgDropletBackupPolicyWeekday = "backup-policy-weekday" + // ArgDropletBackupPolicyHour sets backup policy hour. + ArgDropletBackupPolicyHour = "backup-policy-hour" // ArgIPv6 is an enable IPv6 argument. ArgIPv6 = "enable-ipv6" // ArgPrivateNetworking is an enable private networking argument. diff --git a/commands/commands_test.go b/commands/commands_test.go index db3c20038..10a24b382 100644 --- a/commands/commands_test.go +++ b/commands/commands_test.go @@ -16,6 +16,7 @@ package commands import ( "io" "testing" + "time" "github.com/digitalocean/doctl" "github.com/digitalocean/doctl/do" @@ -123,6 +124,63 @@ var ( } testSnapshotList = do.Snapshots{testSnapshot, testSnapshotSecondary} + + testDropletBackupPolicy = do.DropletBackupPolicy{ + DropletBackupPolicy: &godo.DropletBackupPolicy{ + DropletID: 123, + BackupPolicy: &godo.DropletBackupPolicyConfig{ + Plan: "weekly", + Weekday: "MON", + Hour: 0, + WindowLengthHours: 4, + RetentionPeriodDays: 28, + }, + NextBackupWindow: &godo.BackupWindow{ + Start: &godo.Timestamp{Time: time.Date(2024, time.January, 1, 12, 0, 0, 0, time.UTC)}, + End: &godo.Timestamp{Time: time.Date(2024, time.February, 1, 12, 0, 0, 0, time.UTC)}, + }, + }, + } + + anotherTestDropletBackupPolicy = do.DropletBackupPolicy{ + DropletBackupPolicy: &godo.DropletBackupPolicy{ + DropletID: 123, + BackupPolicy: &godo.DropletBackupPolicyConfig{ + Plan: "daily", + Hour: 12, + WindowLengthHours: 4, + RetentionPeriodDays: 7, + }, + NextBackupWindow: &godo.BackupWindow{ + Start: &godo.Timestamp{Time: time.Date(2024, time.January, 1, 12, 0, 0, 0, time.UTC)}, + End: &godo.Timestamp{Time: time.Date(2024, time.February, 1, 12, 0, 0, 0, time.UTC)}, + }, + }, + } + + testDropletBackupPolicies = do.DropletBackupPolicies{testDropletBackupPolicy, anotherTestDropletBackupPolicy} + + testDropletSupportedBackupPolicy = do.DropletSupportedBackupPolicy{ + SupportedBackupPolicy: &godo.SupportedBackupPolicy{ + Name: "daily", + PossibleWindowStarts: []int{0, 4, 8, 12, 16, 20}, + WindowLengthHours: 4, + RetentionPeriodDays: 7, + PossibleDays: []string{}, + }, + } + + anotherTestDropletSupportedBackupPolicy = do.DropletSupportedBackupPolicy{ + SupportedBackupPolicy: &godo.SupportedBackupPolicy{ + Name: "weekly", + PossibleWindowStarts: []int{0, 4, 8, 12, 16, 20}, + WindowLengthHours: 4, + RetentionPeriodDays: 28, + PossibleDays: []string{"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"}, + }, + } + + testDropletSupportedBackupPolicies = do.DropletSupportedBackupPolicies{testDropletSupportedBackupPolicy, anotherTestDropletSupportedBackupPolicy} ) func assertCommandNames(t *testing.T, cmd *Command, expected ...string) { diff --git a/commands/displayers/droplet_backup_policies.go b/commands/displayers/droplet_backup_policies.go new file mode 100644 index 000000000..366f77add --- /dev/null +++ b/commands/displayers/droplet_backup_policies.go @@ -0,0 +1,53 @@ +package displayers + +import ( + "io" + + "github.com/digitalocean/doctl/do" +) + +type DropletBackupPolicy struct { + DropletBackupPolicies []do.DropletBackupPolicy +} + +var _ Displayable = &DropletBackupPolicy{} + +func (d *DropletBackupPolicy) JSON(out io.Writer) error { + return writeJSON(d.DropletBackupPolicies, out) +} + +func (d *DropletBackupPolicy) Cols() []string { + cols := []string{ + "DropletID", "BackupEnabled", "BackupPolicyPlan", "BackupPolicyWeekday", "BackupPolicyHour", + "BackupPolicyWindowLengthHours", "BackupPolicyRetentionPeriodDays", + "NextBackupWindowStart", "NextBackupWindowEnd", + } + return cols +} + +func (d *DropletBackupPolicy) ColMap() map[string]string { + return map[string]string{ + "DropletID": "Droplet ID", "BackupEnabled": "Enabled", + "BackupPolicyPlan": "Plan", "BackupPolicyWeekday": "Weekday", "BackupPolicyHour": "Hour", + "BackupPolicyWindowLengthHours": "Window Length Hours", "BackupPolicyRetentionPeriodDays": "Retention Period Days", + "NextBackupWindowStart": "Next Window Start", "NextBackupWindowEnd": "Next Window End", + } +} + +func (d *DropletBackupPolicy) KV() []map[string]any { + out := make([]map[string]any, 0) + for _, policy := range d.DropletBackupPolicies { + if policy.BackupPolicy != nil && policy.NextBackupWindow != nil { + m := map[string]any{ + "DropletID": policy.DropletID, "BackupEnabled": policy.BackupEnabled, + "BackupPolicyPlan": policy.BackupPolicy.Plan, + "BackupPolicyWeekday": policy.BackupPolicy.Weekday, "BackupPolicyHour": policy.BackupPolicy.Hour, + "BackupPolicyWindowLengthHours": policy.BackupPolicy.WindowLengthHours, "BackupPolicyRetentionPeriodDays": policy.BackupPolicy.RetentionPeriodDays, + "NextBackupWindowStart": policy.NextBackupWindow.Start, "NextBackupWindowEnd": policy.NextBackupWindow.End, + } + out = append(out, m) + } + } + + return out +} diff --git a/commands/displayers/droplet_supported_backup_policies.go b/commands/displayers/droplet_supported_backup_policies.go new file mode 100644 index 000000000..c617047b0 --- /dev/null +++ b/commands/displayers/droplet_supported_backup_policies.go @@ -0,0 +1,44 @@ +package displayers + +import ( + "io" + + "github.com/digitalocean/doctl/do" +) + +type DropletSupportedBackupPolicy struct { + DropletSupportedBackupPolicies []do.DropletSupportedBackupPolicy +} + +var _ Displayable = &DropletSupportedBackupPolicy{} + +func (d *DropletSupportedBackupPolicy) JSON(out io.Writer) error { + return writeJSON(d.DropletSupportedBackupPolicies, out) +} + +func (d *DropletSupportedBackupPolicy) Cols() []string { + cols := []string{ + "Name", "PossibleWindowStarts", "WindowLengthHours", "RetentionPeriodDays", "PossibleDays", + } + return cols +} + +func (d *DropletSupportedBackupPolicy) ColMap() map[string]string { + return map[string]string{ + "Name": "Name", "PossibleWindowStarts": "Possible Window Starts", + "WindowLengthHours": "Window Length Hours", "RetentionPeriodDays": "Retention Period Days", "PossibleDays": "Possible Days", + } +} + +func (d *DropletSupportedBackupPolicy) KV() []map[string]any { + out := make([]map[string]any, 0) + for _, supported := range d.DropletSupportedBackupPolicies { + m := map[string]any{ + "Name": supported.Name, "PossibleWindowStarts": supported.PossibleWindowStarts, "WindowLengthHours": supported.WindowLengthHours, + "RetentionPeriodDays": supported.RetentionPeriodDays, "PossibleDays": supported.PossibleDays, + } + out = append(out, m) + } + + return out +} diff --git a/commands/droplet_actions.go b/commands/droplet_actions.go index af9077f42..fa8f93994 100644 --- a/commands/droplet_actions.go +++ b/commands/droplet_actions.go @@ -17,6 +17,7 @@ import ( "github.com/digitalocean/doctl" "github.com/digitalocean/doctl/commands/displayers" "github.com/digitalocean/doctl/do" + "github.com/digitalocean/godo" "github.com/spf13/cobra" ) @@ -72,8 +73,11 @@ You can use Droplet actions to perform tasks on a Droplet, such as rebooting, re cmdDropletActionEnableBackups := CmdBuilder(cmd, RunDropletActionEnableBackups, "enable-backups ", "Enable backups on a Droplet", `Enables backups on a Droplet. This automatically creates and stores a disk image of the Droplet. By default, backups happen daily.`, Writer, displayerType(&displayers.Action{})) + AddStringFlag(cmdDropletActionEnableBackups, doctl.ArgDropletBackupPolicyPlan, "", "", `Backup policy frequency plan.`) + AddStringFlag(cmdDropletActionEnableBackups, doctl.ArgDropletBackupPolicyWeekday, "", "", `Backup policy weekday.`) + AddIntFlag(cmdDropletActionEnableBackups, doctl.ArgDropletBackupPolicyHour, "", 0, `Backup policy hour.`) AddBoolFlag(cmdDropletActionEnableBackups, doctl.ArgCommandWait, "", false, "Wait for action to complete") - cmdDropletActionEnableBackups.Example = `The following example enables backups on a Droplet with the ID ` + "`" + `386734086` + "`" + `: doctl compute droplet-action enable-backups 386734086` + cmdDropletActionEnableBackups.Example = `The following example enables backups on a Droplet with the ID ` + "`" + `386734086` + "` with a backup policy flag" + `: doctl compute droplet-action enable-backups 386734086 --backup-policy-plan weekly --backup-policy-weekday SUN --backup-policy-hour 4` cmdDropletActionDisableBackups := CmdBuilder(cmd, RunDropletActionDisableBackups, "disable-backups ", "Disable backups on a Droplet", `Disables backups on a Droplet. This does not delete existing backups.`, Writer, @@ -81,6 +85,15 @@ You can use Droplet actions to perform tasks on a Droplet, such as rebooting, re AddBoolFlag(cmdDropletActionDisableBackups, doctl.ArgCommandWait, "", false, "Instruct the terminal to wait for the action to complete before returning access to the user") cmdDropletActionDisableBackups.Example = `The following example disables backups on a Droplet with the ID ` + "`" + `386734086` + "`" + `: doctl compute droplet-action disable-backups 386734086` + cmdDropletActionChangeBackupPolicy := CmdBuilder(cmd, RunDropletActionChangeBackupPolicy, + "change-backup-policy ", "Change backup policy on a Droplet", `Changes backup policy for a Droplet with enabled backups.`, Writer, + displayerType(&displayers.Action{})) + AddStringFlag(cmdDropletActionChangeBackupPolicy, doctl.ArgDropletBackupPolicyPlan, "", "", `Backup policy frequency plan.`, requiredOpt()) + AddStringFlag(cmdDropletActionChangeBackupPolicy, doctl.ArgDropletBackupPolicyWeekday, "", "", `Backup policy weekday.`) + AddIntFlag(cmdDropletActionChangeBackupPolicy, doctl.ArgDropletBackupPolicyHour, "", 0, `Backup policy hour.`) + AddBoolFlag(cmdDropletActionChangeBackupPolicy, doctl.ArgCommandWait, "", false, "Wait for action to complete") + cmdDropletActionChangeBackupPolicy.Example = `The following example changes backup policy on a Droplet with the ID ` + "`" + `386734086` + "`" + `: doctl compute droplet-action change-backup-policy 386734086 --backup-policy-plan weekly --backup-policy-weekday SUN --backup-policy-hour 4` + cmdDropletActionReboot := CmdBuilder(cmd, RunDropletActionReboot, "reboot ", "Reboot a Droplet", `Reboots a Droplet. A reboot action is an attempt to reboot the Droplet in a graceful way, similar to using the reboot command from the Droplet's console.`, Writer, displayerType(&displayers.Action{})) @@ -242,8 +255,12 @@ func RunDropletActionEnableBackups(c *CmdConfig) error { return nil, err } - a, err := das.EnableBackups(id) - return a, err + policy, err := readDropletBackupPolicy(c) + if err == nil && policy != nil { + return das.EnableBackupsWithPolicy(id, policy) + } + + return das.EnableBackups(id) } return performAction(c, fn) @@ -268,6 +285,63 @@ func RunDropletActionDisableBackups(c *CmdConfig) error { return performAction(c, fn) } +func readDropletBackupPolicy(c *CmdConfig) (*godo.DropletBackupPolicyRequest, error) { + policyPlan, err := c.Doit.GetString(c.NS, doctl.ArgDropletBackupPolicyPlan) + if err != nil { + return nil, err + } + + // For cases when backup policy is not specified. + if policyPlan == "" { + return nil, nil + } + + policyHour, err := c.Doit.GetInt(c.NS, doctl.ArgDropletBackupPolicyHour) + if err != nil { + return nil, err + } + + policy := godo.DropletBackupPolicyRequest{ + Plan: policyPlan, + Hour: &policyHour, + } + + if policyPlan == "weekly" { + policyWeekday, err := c.Doit.GetString(c.NS, doctl.ArgDropletBackupPolicyWeekday) + if err != nil { + return nil, err + } + policy.Weekday = policyWeekday + } + + return &policy, nil +} + +// RunDropletActionChangeBackupPolicy changes backup policy for a droplet. +func RunDropletActionChangeBackupPolicy(c *CmdConfig) error { + fn := func(das do.DropletActionsService) (*do.Action, error) { + err := ensureOneArg(c) + if err != nil { + return nil, err + } + + id, err := ContextualAtoi(c.Args[0], dropletIDResource) + if err != nil { + return nil, err + } + + policy, err := readDropletBackupPolicy(c) + if err != nil { + return nil, err + } + + a, err := das.ChangeBackupPolicy(id, policy) + return a, err + } + + return performAction(c, fn) +} + // RunDropletActionReboot reboots a droplet. func RunDropletActionReboot(c *CmdConfig) error { fn := func(das do.DropletActionsService) (*do.Action, error) { diff --git a/commands/droplet_actions_test.go b/commands/droplet_actions_test.go index 4aa76ef9b..ceba2dec8 100644 --- a/commands/droplet_actions_test.go +++ b/commands/droplet_actions_test.go @@ -17,13 +17,15 @@ import ( "testing" "github.com/digitalocean/doctl" + "github.com/digitalocean/godo" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestDropletActionCommand(t *testing.T) { cmd := DropletAction() assert.NotNil(t, cmd) - assertCommandNames(t, cmd, "change-kernel", "enable-backups", "disable-backups", "enable-ipv6", "enable-private-networking", "get", "power-cycle", "power-off", "power-on", "password-reset", "reboot", "rebuild", "rename", "resize", "restore", "shutdown", "snapshot") + assertCommandNames(t, cmd, "change-kernel", "change-backup-policy", "enable-backups", "disable-backups", "enable-ipv6", "enable-private-networking", "get", "power-cycle", "power-off", "power-on", "password-reset", "reboot", "rebuild", "rename", "resize", "restore", "shutdown", "snapshot") } func TestDropletActionsChangeKernel(t *testing.T) { @@ -59,6 +61,24 @@ func TestDropletActionsEnableBackups(t *testing.T) { err := RunDropletActionEnableBackups(config) assert.EqualError(t, err, `expected to be a positive integer, got "my-test-id"`) }) + // Enable backups with a backup policy applied. + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + policy := &godo.DropletBackupPolicyRequest{ + Plan: "weekly", + Weekday: "SAT", + Hour: godo.PtrTo(0), + } + + tm.dropletActions.EXPECT().EnableBackupsWithPolicy(1, policy).Times(1).Return(&testAction, nil) + + config.Args = append(config.Args, "1") + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyPlan, policy.Plan) + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyWeekday, policy.Weekday) + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyHour, policy.Hour) + + err := RunDropletActionEnableBackups(config) + require.NoError(t, err) + }) } func TestDropletActionsDisableBackups(t *testing.T) { @@ -78,6 +98,26 @@ func TestDropletActionsDisableBackups(t *testing.T) { }) } +func TestDropletActionsChangeBackupPolicy(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + policy := &godo.DropletBackupPolicyRequest{ + Plan: "weekly", + Weekday: "SAT", + Hour: godo.PtrTo(0), + } + + tm.dropletActions.EXPECT().ChangeBackupPolicy(1, policy).Times(1).Return(&testAction, nil) + + config.Args = append(config.Args, "1") + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyPlan, policy.Plan) + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyWeekday, policy.Weekday) + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyHour, policy.Hour) + + err := RunDropletActionChangeBackupPolicy(config) + require.NoError(t, err) + }) +} + func TestDropletActionsEnableIPv6(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { tm.dropletActions.EXPECT().EnableIPv6(1).Return(&testAction, nil) diff --git a/commands/droplets.go b/commands/droplets.go index 139eda2ff..7b8ca46fd 100644 --- a/commands/droplets.go +++ b/commands/droplets.go @@ -81,6 +81,9 @@ If you do not specify a region, the Droplet is created in the default region for AddStringFlag(cmdDropletCreate, doctl.ArgSizeSlug, "", "", "A `slug` indicating the Droplet's number of vCPUs, RAM, and disk size. For example, `s-1vcpu-1gb` specifies a Droplet with one vCPU and 1 GiB of RAM. The disk size is defined by the slug's plan. Run `doctl compute size list` for a list of valid size slugs and their disk sizes.", requiredOpt()) AddBoolFlag(cmdDropletCreate, doctl.ArgBackups, "", false, "Enables backups for the Droplet. By default, backups are created on a daily basis.") + AddStringFlag(cmdDropletCreate, doctl.ArgDropletBackupPolicyPlan, "", "", `Backup policy frequency plan.`) + AddStringFlag(cmdDropletCreate, doctl.ArgDropletBackupPolicyWeekday, "", "", `Backup policy weekday.`) + AddIntFlag(cmdDropletCreate, doctl.ArgDropletBackupPolicyHour, "", 0, `Backup policy hour.`) AddBoolFlag(cmdDropletCreate, doctl.ArgIPv6, "", false, "Enables IPv6 support and assigns an IPv6 address to the Droplet") AddBoolFlag(cmdDropletCreate, doctl.ArgPrivateNetworking, "", false, "Enables private networking for the Droplet by provisioning it inside of your account's default VPC for the region") AddBoolFlag(cmdDropletCreate, doctl.ArgMonitoring, "", false, "Installs the DigitalOcean agent for additional monitoring") @@ -134,6 +137,7 @@ If you do not specify a region, the Droplet is created in the default region for cmdRunDropletUntag.Example = `The following example removes the tag ` + "`" + `frontend` + "`" + ` from a Droplet with the ID ` + "`" + `386734086` + "`" + `: doctl compute droplet untag 386734086 --tag-name frontend` cmd.AddCommand(dropletOneClicks()) + cmd.AddCommand(dropletBackupPolicies()) return cmd } @@ -197,6 +201,11 @@ func RunDropletCreate(c *CmdConfig) error { return err } + backupPolicy, err := readDropletBackupPolicy(c) + if err != nil { + return err + } + ipv6, err := c.Doit.GetBool(c.NS, doctl.ArgIPv6) if err != nil { return err @@ -298,6 +307,7 @@ func RunDropletCreate(c *CmdConfig) error { Image: createImage, Volumes: volumes, Backups: backups, + BackupPolicy: backupPolicy, IPv6: ipv6, PrivateNetworking: privateNetworking, Monitoring: monitoring, @@ -788,6 +798,30 @@ func buildDropletSummary(ds do.DropletsService) (*dropletSummary, error) { return &sum, nil } +// dropletBackupPolicies creates the backup-policies command subtree. +func dropletBackupPolicies() *Command { + cmd := &Command{ + Command: &cobra.Command{ + Use: "backup-policies", + Short: "Display commands for Droplet's backup policies.", + Long: "The commands under `doctl compute droplet backup-policies` are for displaying the commands for Droplet's backup policies.", + }, + } + + cmdDropletGetBackupPolicy := CmdBuilder(cmd, RunDropletGetBackupPolicy, "get ", "Get droplet's backup policy", `Retrieves a backup policy of a Droplet.`, Writer, + displayerType(&displayers.DropletBackupPolicy{})) + cmdDropletGetBackupPolicy.Example = `The following example retrieves a backup policy for a Droplet with the ID ` + "`" + `386734086` + "`" + `. The command also uses the ` + "`" + `--format` + "`" + ` flag to only return the Droplet's id and backup policy plan: doctl compute droplet backup-policies get 386734086 --format DropletID,BackupPolicyPlan` + AddStringFlag(cmdDropletGetBackupPolicy, doctl.ArgTemplate, "", "", "Go template format. Sample values: ```{{.DropletID}}`, `{{.BackupEnabled}}`, `{{.BackupPolicy.Plan}}`, `{{.BackupPolicy.Weekday}}`, `{{.BackupPolicy.Hour}}`, `{{.BackupPolicy.Plan}}`, `{{.BackupPolicy.WindowLengthHours}}`, `{{.BackupPolicy.RetentionPeriodDays}}`, `{{.NextBackupWindow.Start}}`, `{{.NextBackupWindow.End}}`") + + cmdDropletListBackupPolicies := CmdBuilder(cmd, RunDropletListBackupPolicies, "list", "List backup policies for all Droplets", `List droplet backup policies for all existing Droplets.`, Writer, aliasOpt("ls")) + cmdDropletListBackupPolicies.Example = `The following example list backup policies for all existing Droplets: doctl compute droplet backup-policies list` + + cmdDropletListSupportedBackupPolicies := CmdBuilder(cmd, RunDropletListSupportedBackupPolicies, "list-supported", "List of all supported droplet backup policies", `List of all supported droplet backup policies.`, Writer) + cmdDropletListSupportedBackupPolicies.Example = `The following example list all supported backup policies for Droplets: doctl compute droplet backup-policies list-supported` + + return cmd +} + // kubernetesOneClicks creates the 1-click command. func dropletOneClicks() *Command { cmd := &Command{ @@ -819,3 +853,62 @@ func RunDropletOneClickList(c *CmdConfig) error { return c.Display(items) } + +// RunDropletGetBackupPolicy retrieves a backup policy for a Droplet. +func RunDropletGetBackupPolicy(c *CmdConfig) error { + ds := c.Droplets() + + id, err := getDropletIDArg(c.NS, c.Args) + if err != nil { + return err + } + + policy, err := ds.GetBackupPolicy(id) + if err != nil { + return err + } + + item := &displayers.DropletBackupPolicy{DropletBackupPolicies: []do.DropletBackupPolicy{*policy}} + + getTemplate, err := c.Doit.GetString(c.NS, doctl.ArgTemplate) + if err != nil { + return err + } + + if getTemplate != "" { + t := template.New("Get template") + t, err = t.Parse(getTemplate) + if err != nil { + return err + } + return t.Execute(c.Out, policy) + } + + return c.Display(item) +} + +// RunDropletListBackupPolicies list backup policies for all existing Droplets. +func RunDropletListBackupPolicies(c *CmdConfig) error { + ds := c.Droplets() + + policies, err := ds.ListBackupPolicies() + if err != nil { + return err + } + + items := &displayers.DropletBackupPolicy{DropletBackupPolicies: policies} + return c.Display(items) +} + +// RunDropletListSupportedBackupPolicies list all supported backup policies for Droplets. +func RunDropletListSupportedBackupPolicies(c *CmdConfig) error { + ds := c.Droplets() + + policies, err := ds.ListSupportedBackupPolicies() + if err != nil { + return err + } + + items := &displayers.DropletSupportedBackupPolicy{DropletSupportedBackupPolicies: policies} + return c.Display(items) +} diff --git a/commands/droplets_test.go b/commands/droplets_test.go index 2cc7ceeda..aadfb9714 100644 --- a/commands/droplets_test.go +++ b/commands/droplets_test.go @@ -53,7 +53,7 @@ var ( func TestDropletCommand(t *testing.T) { cmd := Droplet() assert.NotNil(t, cmd) - assertCommandNames(t, cmd, "1-click", "actions", "backups", "create", "delete", "get", "kernels", "list", "neighbors", "snapshots", "tag", "untag") + assertCommandNames(t, cmd, "1-click", "actions", "backups", "backup-policies", "create", "delete", "get", "kernels", "list", "neighbors", "snapshots", "tag", "untag") } func TestDropletActionList(t *testing.T) { @@ -117,6 +117,56 @@ func TestDropletCreate(t *testing.T) { }) } +func TestDropletCreateWithBackupPolicy(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + dropletPolicy := godo.DropletBackupPolicyRequest{ + Plan: "weekly", + Weekday: "SAT", + Hour: godo.PtrTo(0), + } + volumeUUID := "00000000-0000-4000-8000-000000000000" + vpcUUID := "00000000-0000-4000-8000-000000000000" + dcr := &godo.DropletCreateRequest{ + Name: "droplet", + Region: "dev0", + Size: "1gb", + Image: godo.DropletCreateImage{ID: 0, Slug: "image"}, + SSHKeys: []godo.DropletCreateSSHKey{}, + Volumes: []godo.DropletCreateVolume{ + {Name: "test-volume"}, + {ID: volumeUUID}, + }, + Backups: true, + IPv6: false, + PrivateNetworking: false, + Monitoring: false, + VPCUUID: vpcUUID, + UserData: "#cloud-config", + Tags: []string{"one", "two"}, + BackupPolicy: &dropletPolicy, + } + + tm.droplets.EXPECT().Create(dcr, false).Return(&testDroplet, nil) + + config.Args = append(config.Args, "droplet") + + config.Doit.Set(config.NS, doctl.ArgRegionSlug, "dev0") + config.Doit.Set(config.NS, doctl.ArgSizeSlug, "1gb") + config.Doit.Set(config.NS, doctl.ArgImage, "image") + config.Doit.Set(config.NS, doctl.ArgUserData, "#cloud-config") + config.Doit.Set(config.NS, doctl.ArgVPCUUID, vpcUUID) + config.Doit.Set(config.NS, doctl.ArgVolumeList, []string{"test-volume", volumeUUID}) + config.Doit.Set(config.NS, doctl.ArgTagNames, []string{"one", "two"}) + config.Doit.Set(config.NS, doctl.ArgBackups, true) + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyPlan, dropletPolicy.Plan) + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyWeekday, dropletPolicy.Weekday) + config.Doit.Set(config.NS, doctl.ArgDropletBackupPolicyHour, dropletPolicy.Hour) + + err := RunDropletCreate(config) + assert.NoError(t, err) + }) +} + func TestDropletCreateWithTag(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { dcr := &godo.DropletCreateRequest{ @@ -601,3 +651,30 @@ func TestDropletCreateWithAgent(t *testing.T) { }) } } + +func TestDropletGetBackupPolicy(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + tm.droplets.EXPECT().GetBackupPolicy(testDropletBackupPolicy.DropletID).Return(&testDropletBackupPolicy, nil) + + config.Args = append(config.Args, strconv.Itoa(testDropletBackupPolicy.DropletID)) + + err := RunDropletGetBackupPolicy(config) + assert.NoError(t, err) + }) +} + +func TestDropletListBackupPolicies(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + tm.droplets.EXPECT().ListBackupPolicies().Return(testDropletBackupPolicies, nil) + err := RunDropletListBackupPolicies(config) + assert.NoError(t, err) + }) +} + +func TestDropletListSupportedBackupPolicies(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + tm.droplets.EXPECT().ListSupportedBackupPolicies().Return(testDropletSupportedBackupPolicies, nil) + err := RunDropletListSupportedBackupPolicies(config) + assert.NoError(t, err) + }) +} diff --git a/do/droplet_actions.go b/do/droplet_actions.go index 45d6ef1cd..76de2a459 100644 --- a/do/droplet_actions.go +++ b/do/droplet_actions.go @@ -39,6 +39,8 @@ type DropletActionsService interface { EnableBackupsByTag(string) (Actions, error) DisableBackups(int) (*Action, error) DisableBackupsByTag(string) (Actions, error) + ChangeBackupPolicy(int, *godo.DropletBackupPolicyRequest) (*Action, error) + EnableBackupsWithPolicy(int, *godo.DropletBackupPolicyRequest) (*Action, error) PasswordReset(int) (*Action, error) RebuildByImageID(int, int) (*Action, error) RebuildByImageSlug(int, string) (*Action, error) @@ -176,6 +178,16 @@ func (das *dropletActionsService) DisableBackupsByTag(tag string) (Actions, erro return das.handleTagActionResponse(a, err) } +func (das *dropletActionsService) ChangeBackupPolicy(id int, policy *godo.DropletBackupPolicyRequest) (*Action, error) { + a, _, err := das.client.DropletActions.ChangeBackupPolicy(context.TODO(), id, policy) + return das.handleActionResponse(a, err) +} + +func (das *dropletActionsService) EnableBackupsWithPolicy(id int, policy *godo.DropletBackupPolicyRequest) (*Action, error) { + a, _, err := das.client.DropletActions.EnableBackupsWithPolicy(context.TODO(), id, policy) + return das.handleActionResponse(a, err) +} + func (das *dropletActionsService) PasswordReset(id int) (*Action, error) { a, _, err := das.client.DropletActions.PasswordReset(context.TODO(), id) return das.handleActionResponse(a, err) diff --git a/do/droplets.go b/do/droplets.go index e3e325a15..999364a48 100644 --- a/do/droplets.go +++ b/do/droplets.go @@ -49,6 +49,22 @@ type Kernel struct { // Kernels is a slice of Kernel. type Kernels []Kernel +// DropletBackupPolicy is a wrapper for godo.DropletBackupPolicy. +type DropletBackupPolicy struct { + *godo.DropletBackupPolicy +} + +// DropletBackupPolicies is a slice of DropletBackupPolicy. +type DropletBackupPolicies []DropletBackupPolicy + +// DropletSupportedBackupPolicy is a wrapper for godo.SupportedBackupPolicy. +type DropletSupportedBackupPolicy struct { + *godo.SupportedBackupPolicy +} + +// DropletSupportedBackupPolicies is a slice of DropletSupportedBackupPolicy. +type DropletSupportedBackupPolicies []DropletSupportedBackupPolicy + // DropletsService is an interface for interacting with DigitalOcean's droplet api. type DropletsService interface { List() (Droplets, error) @@ -64,6 +80,9 @@ type DropletsService interface { Backups(int) (Images, error) Actions(int) (Actions, error) Neighbors(int) (Droplets, error) + GetBackupPolicy(int) (*DropletBackupPolicy, error) + ListBackupPolicies() (DropletBackupPolicies, error) + ListSupportedBackupPolicies() (DropletSupportedBackupPolicies, error) } type dropletsService struct { @@ -338,3 +357,57 @@ func (ds *dropletsService) Neighbors(id int) (Droplets, error) { return droplets, nil } + +func (ds *dropletsService) GetBackupPolicy(id int) (*DropletBackupPolicy, error) { + policy, _, err := ds.client.Droplets.GetBackupPolicy(context.TODO(), id) + if err != nil { + return nil, err + } + + return &DropletBackupPolicy{policy}, nil +} + +func (ds *dropletsService) ListBackupPolicies() (DropletBackupPolicies, error) { + f := func(opt *godo.ListOptions) ([]any, *godo.Response, error) { + policies, resp, err := ds.client.Droplets.ListBackupPolicies(context.TODO(), opt) + if err != nil { + return nil, nil, err + } + + pl := make([]any, len(policies)) + i := 0 + for _, value := range policies { + pl[i] = value + i++ + } + + return pl, resp, err + } + + si, err := PaginateResp(f) + if err != nil { + return nil, err + } + + list := make(DropletBackupPolicies, len(si)) + for i := range si { + p := si[i].(*godo.DropletBackupPolicy) + list[i] = DropletBackupPolicy{DropletBackupPolicy: p} + } + + return list, nil +} + +func (ds *dropletsService) ListSupportedBackupPolicies() (DropletSupportedBackupPolicies, error) { + policies, _, err := ds.client.Droplets.ListSupportedBackupPolicies(context.TODO()) + if err != nil { + return nil, err + } + + list := make(DropletSupportedBackupPolicies, len(policies)) + for i := range policies { + list[i] = DropletSupportedBackupPolicy{SupportedBackupPolicy: policies[i]} + } + + return list, nil +} diff --git a/do/mocks/DropletActionService.go b/do/mocks/DropletActionService.go index 1141c5083..93a51cb46 100644 --- a/do/mocks/DropletActionService.go +++ b/do/mocks/DropletActionService.go @@ -13,6 +13,7 @@ import ( reflect "reflect" do "github.com/digitalocean/doctl/do" + godo "github.com/digitalocean/godo" gomock "go.uber.org/mock/gomock" ) @@ -40,6 +41,21 @@ func (m *MockDropletActionsService) EXPECT() *MockDropletActionsServiceMockRecor return m.recorder } +// ChangeBackupPolicy mocks base method. +func (m *MockDropletActionsService) ChangeBackupPolicy(arg0 int, arg1 *godo.DropletBackupPolicyRequest) (*do.Action, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeBackupPolicy", arg0, arg1) + ret0, _ := ret[0].(*do.Action) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeBackupPolicy indicates an expected call of ChangeBackupPolicy. +func (mr *MockDropletActionsServiceMockRecorder) ChangeBackupPolicy(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeBackupPolicy", reflect.TypeOf((*MockDropletActionsService)(nil).ChangeBackupPolicy), arg0, arg1) +} + // ChangeKernel mocks base method. func (m *MockDropletActionsService) ChangeKernel(arg0, arg1 int) (*do.Action, error) { m.ctrl.T.Helper() @@ -115,6 +131,21 @@ func (mr *MockDropletActionsServiceMockRecorder) EnableBackupsByTag(arg0 any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnableBackupsByTag", reflect.TypeOf((*MockDropletActionsService)(nil).EnableBackupsByTag), arg0) } +// EnableBackupsWithPolicy mocks base method. +func (m *MockDropletActionsService) EnableBackupsWithPolicy(arg0 int, arg1 *godo.DropletBackupPolicyRequest) (*do.Action, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EnableBackupsWithPolicy", arg0, arg1) + ret0, _ := ret[0].(*do.Action) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// EnableBackupsWithPolicy indicates an expected call of EnableBackupsWithPolicy. +func (mr *MockDropletActionsServiceMockRecorder) EnableBackupsWithPolicy(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnableBackupsWithPolicy", reflect.TypeOf((*MockDropletActionsService)(nil).EnableBackupsWithPolicy), arg0, arg1) +} + // EnableIPv6 mocks base method. func (m *MockDropletActionsService) EnableIPv6(arg0 int) (*do.Action, error) { m.ctrl.T.Helper() diff --git a/do/mocks/DropletsService.go b/do/mocks/DropletsService.go index 3f8162f1f..ca6fe87de 100644 --- a/do/mocks/DropletsService.go +++ b/do/mocks/DropletsService.go @@ -144,6 +144,21 @@ func (mr *MockDropletsServiceMockRecorder) Get(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockDropletsService)(nil).Get), arg0) } +// GetBackupPolicy mocks base method. +func (m *MockDropletsService) GetBackupPolicy(arg0 int) (*do.DropletBackupPolicy, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBackupPolicy", arg0) + ret0, _ := ret[0].(*do.DropletBackupPolicy) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBackupPolicy indicates an expected call of GetBackupPolicy. +func (mr *MockDropletsServiceMockRecorder) GetBackupPolicy(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBackupPolicy", reflect.TypeOf((*MockDropletsService)(nil).GetBackupPolicy), arg0) +} + // Kernels mocks base method. func (m *MockDropletsService) Kernels(arg0 int) (do.Kernels, error) { m.ctrl.T.Helper() @@ -174,6 +189,21 @@ func (mr *MockDropletsServiceMockRecorder) List() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockDropletsService)(nil).List)) } +// ListBackupPolicies mocks base method. +func (m *MockDropletsService) ListBackupPolicies() (do.DropletBackupPolicies, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListBackupPolicies") + ret0, _ := ret[0].(do.DropletBackupPolicies) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListBackupPolicies indicates an expected call of ListBackupPolicies. +func (mr *MockDropletsServiceMockRecorder) ListBackupPolicies() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBackupPolicies", reflect.TypeOf((*MockDropletsService)(nil).ListBackupPolicies)) +} + // ListByTag mocks base method. func (m *MockDropletsService) ListByTag(arg0 string) (do.Droplets, error) { m.ctrl.T.Helper() @@ -189,6 +219,21 @@ func (mr *MockDropletsServiceMockRecorder) ListByTag(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByTag", reflect.TypeOf((*MockDropletsService)(nil).ListByTag), arg0) } +// ListSupportedBackupPolicies mocks base method. +func (m *MockDropletsService) ListSupportedBackupPolicies() (do.DropletSupportedBackupPolicies, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListSupportedBackupPolicies") + ret0, _ := ret[0].(do.DropletSupportedBackupPolicies) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListSupportedBackupPolicies indicates an expected call of ListSupportedBackupPolicies. +func (mr *MockDropletsServiceMockRecorder) ListSupportedBackupPolicies() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSupportedBackupPolicies", reflect.TypeOf((*MockDropletsService)(nil).ListSupportedBackupPolicies)) +} + // ListWithGPUs mocks base method. func (m *MockDropletsService) ListWithGPUs() (do.Droplets, error) { m.ctrl.T.Helper() diff --git a/integration/compute_droplet_action_test.go b/integration/compute_droplet_action_test.go index b9f54c9c1..4b148c3a3 100644 --- a/integration/compute_droplet_action_test.go +++ b/integration/compute_droplet_action_test.go @@ -48,6 +48,8 @@ var _ = suite("compute/droplet-action", func(t *testing.T, when spec.G, it spec. "/v2/droplets/4743/actions": {method: http.MethodPost, body: `{"image":9999,"type":"rebuild"}`}, "/v2/droplets/884/actions": {method: http.MethodPost, body: `{"disk":true,"size":"bigger","type":"resize"}`}, "/v2/droplets/789/actions/954": {method: http.MethodGet, body: `{}`}, + "/v2/droplets/66/actions": {method: http.MethodPost, body: `{"type":"enable_backups","backup_policy":{"plan":"weekly","weekday":"TUE","hour":16}}`}, + "/v2/droplets/67/actions": {method: http.MethodPost, body: `{"type":"change_backup_policy","backup_policy":{"plan":"weekly","weekday":"WED","hour":4}}`}, } auth := req.Header.Get("Authorization") @@ -78,7 +80,11 @@ var _ = suite("compute/droplet-action", func(t *testing.T, when spec.G, it spec. expect.JSONEq(matchRequest.body, string(reqBody)) } - w.Write([]byte(dropletActionResponse)) + if strings.Contains(matchRequest.body, "change_backup_policy") { + w.Write([]byte(dropletActionChangeBackupResponse)) + } else { + w.Write([]byte(dropletActionResponse)) + } })) }) @@ -110,6 +116,8 @@ var _ = suite("compute/droplet-action", func(t *testing.T, when spec.G, it spec. {desc: "snapshot", args: []string{"snapshot", "48", "--snapshot-name", "best-snapshot"}}, {desc: "get", args: []string{"get", "789", "--action-id", "954"}}, {desc: "g", args: []string{"get", "789", "--action-id", "954"}}, + {desc: "enable backups with policy", args: []string{"enable-backups", "66", "--backup-policy-plan", "weekly", "--backup-policy-weekday", "TUE", "--backup-policy-hour", "16"}}, + {desc: "change backup policy", args: []string{"change-backup-policy", "67", "--backup-policy-plan", "weekly", "--backup-policy-weekday", "WED", "--backup-policy-hour", "4"}}, } for _, c := range cases { @@ -125,7 +133,11 @@ var _ = suite("compute/droplet-action", func(t *testing.T, when spec.G, it spec. output, err := cmd.CombinedOutput() expect.NoError(err, fmt.Sprintf("received error output: %s", output)) - expect.Equal(strings.TrimSpace(dropletActionOutput), strings.TrimSpace(string(output))) + if strings.Contains(c.desc, "change backup policy") { + expect.Equal(strings.TrimSpace(dropletActionChangeBackupOutput), strings.TrimSpace(string(output))) + } else { + expect.Equal(strings.TrimSpace(dropletActionOutput), strings.TrimSpace(string(output))) + } }) }) } @@ -156,5 +168,30 @@ ID Status Type Started At Co "region_slug": "nyc3" } } +` + dropletActionChangeBackupOutput = ` +ID Status Type Started At Completed At Resource ID Resource Type Region +36804745 in-progress change_backup_policy 2014-11-14 16:30:56 +0000 UTC 3164450 droplet nyc3 + ` + dropletActionChangeBackupResponse = ` +{ + "action": { + "id": 36804745, + "status": "in-progress", + "type": "change_backup_policy", + "started_at": "2014-11-14T16:30:56Z", + "completed_at": null, + "resource_id": 3164450, + "resource_type": "droplet", + "region": { + "name": "New York 3", + "slug": "nyc3", + "sizes": [ "s-24vcpu-128gb" ], + "features": [ "image_transfer" ], + "available": true + }, + "region_slug": "nyc3" + } +} ` ) diff --git a/integration/droplet_backup_policies_get_test.go b/integration/droplet_backup_policies_get_test.go new file mode 100644 index 000000000..1a1f7d8e7 --- /dev/null +++ b/integration/droplet_backup_policies_get_test.go @@ -0,0 +1,158 @@ +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/sclevine/spec" + "github.com/stretchr/testify/require" +) + +var _ = suite("compute/droplet/backup-policies/get", func(t *testing.T, when spec.G, it spec.S) { + var ( + expect *require.Assertions + server *httptest.Server + configPath string + ) + + it.Before(func() { + expect = require.New(t) + + dir := t.TempDir() + + configPath = filepath.Join(dir, "config.yaml") + + err := os.WriteFile(configPath, []byte(dropletBackupPoliciesGetConfig), 0644) + expect.NoError(err) + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/v2/droplets/5555/backups/policy": + auth := req.Header.Get("Authorization") + if auth != "Bearer special-broken" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte(dropletBackupPoliciesGetResponse)) + default: + dump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatal("failed to dump request") + } + + t.Fatalf("received unknown request: %s", dump) + } + })) + }) + + it.After(func() { + err := os.RemoveAll(configPath) + expect.NoError(err) + }) + + when("all required flags are passed", func() { + it("gets backup policy for the specified droplet ID", func() { + cmd := exec.Command(builtBinaryPath, + "-c", configPath, + "-u", server.URL, + "compute", + "droplet", + "backup-policies", + "get", + "5555", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err, fmt.Sprintf("received error output: %s", output)) + expect.Equal(strings.TrimSpace(dropletBackupPoliciesGetOutput), strings.TrimSpace(string(output))) + }) + }) + + when("passing a format", func() { + it("displays only those columns", func() { + cmd := exec.Command(builtBinaryPath, + "-c", configPath, + "-u", server.URL, + "compute", + "droplet", + "backup-policies", + "get", + "5555", + "--format", "DropletID,BackupPolicyPlan", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err, fmt.Sprintf("received error output: %s", output)) + expect.Equal(strings.TrimSpace(dropletBackupPoliciesGetFormatOutput), strings.TrimSpace(string(output))) + }) + }) + + when("passing a template", func() { + it("renders the template with the values", func() { + cmd := exec.Command(builtBinaryPath, + "-c", configPath, + "-u", server.URL, + "compute", + "droplet", + "backup-policies", + "get", + "5555", + "--template", "this droplet id {{.DropletID}} is making a backup {{.BackupPolicy.Plan}}", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err, fmt.Sprintf("received error output: %s", output)) + expect.Equal(strings.TrimSpace(dropletBackupPoliciesGetTemplateOutput), strings.TrimSpace(string(output))) + }) + }) +}) + +const ( + dropletBackupPoliciesGetConfig = ` +--- +access-token: special-broken +` + dropletBackupPoliciesGetOutput = ` +Droplet ID Enabled Plan Weekday Hour Window Length Hours Retention Period Days Next Window Start Next Window End +5555 true weekly SUN 20 4 28 2024-11-17 20:00:00 +0000 UTC 2024-11-18 00:00:00 +0000 UTC +` + dropletBackupPoliciesGetFormatOutput = ` +Droplet ID Plan +5555 weekly + ` + + dropletBackupPoliciesGetTemplateOutput = ` + this droplet id 5555 is making a backup weekly + ` + dropletBackupPoliciesGetResponse = ` +{ + "policy": { + "droplet_id": 5555, + "backup_enabled": true, + "backup_policy": { + "plan": "weekly", + "weekday": "SUN", + "hour": 20, + "window_length_hours": 4, + "retention_period_days": 28 + }, + "next_backup_window": { + "start": "2024-11-17T20:00:00Z", + "end": "2024-11-18T00:00:00Z" + } + } +}` +) diff --git a/integration/droplet_backup_policies_list_test.go b/integration/droplet_backup_policies_list_test.go new file mode 100644 index 000000000..3616cb4dc --- /dev/null +++ b/integration/droplet_backup_policies_list_test.go @@ -0,0 +1,117 @@ +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/sclevine/spec" + "github.com/stretchr/testify/require" +) + +var _ = suite("compute/droplet/backup-policies/list", func(t *testing.T, when spec.G, it spec.S) { + var ( + expect *require.Assertions + server *httptest.Server + configPath string + ) + + it.Before(func() { + expect = require.New(t) + + dir := t.TempDir() + + configPath = filepath.Join(dir, "config.yaml") + + err := os.WriteFile(configPath, []byte(dropletBackupPoliciesListConfig), 0644) + expect.NoError(err) + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/v2/droplets/backups/policies": + auth := req.Header.Get("Authorization") + if auth != "Bearer special-broken" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte(dropletBackupPoliciesListResponse)) + default: + dump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatal("failed to dump request") + } + + t.Fatalf("received unknown request: %s", dump) + } + })) + }) + + it.After(func() { + err := os.RemoveAll(configPath) + expect.NoError(err) + }) + + when("all required flags are passed", func() { + it("list backup policies for all droplets", func() { + cmd := exec.Command(builtBinaryPath, + "-c", configPath, + "-u", server.URL, + "compute", + "droplet", + "backup-policies", + "list", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err, fmt.Sprintf("received error output: %s", output)) + expect.Equal(strings.TrimSpace(dropletBackupPoliciesListOutput), strings.TrimSpace(string(output))) + }) + }) +}) + +const ( + dropletBackupPoliciesListConfig = ` +--- +access-token: special-broken +` + dropletBackupPoliciesListOutput = ` +Droplet ID Enabled Plan Weekday Hour Window Length Hours Retention Period Days Next Window Start Next Window End +5555 true weekly SUN 20 4 28 2024-11-17 20:00:00 +0000 UTC 2024-11-18 00:00:00 +0000 UTC +` + dropletBackupPoliciesListResponse = ` +{ + "policies": { + "5555": { + "droplet_id": 5555, + "backup_enabled": true, + "backup_policy": { + "plan": "weekly", + "weekday": "SUN", + "hour": 20, + "window_length_hours": 4, + "retention_period_days": 28 + }, + "next_backup_window": { + "start": "2024-11-17T20:00:00Z", + "end": "2024-11-18T00:00:00Z" + } + } + }, + "links": {}, + "meta": { + "total": 1 + } +}` +) diff --git a/integration/droplet_create_test.go b/integration/droplet_create_test.go index 136e64b06..65eb62a99 100644 --- a/integration/droplet_create_test.go +++ b/integration/droplet_create_test.go @@ -235,6 +235,47 @@ var _ = suite("compute/droplet/create", func(t *testing.T, when spec.G, it spec. }) } }) + when("the backup policy is passed", func() { + it("polls until the droplet is created", func() { + cmd := exec.Command(builtBinaryPath, + "-t", "some-magic-token", + "-u", server.URL, + "compute", + "droplet", + "create", + "backup-policy-on-name", + "--image", "a-test-image", + "--region", "a-test-region", + "--size", "a-test-size", + "--vpc-uuid", "00000000-0000-4000-8000-000000000000", + "--enable-backups", + "--backup-policy-plan", "weekly", + "--backup-policy-hour", "4", + "--backup-policy-weekday", "MON", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err, fmt.Sprintf("received error output: %s", output)) + expect.Equal(strings.TrimSpace(dropletCreateOutput), strings.TrimSpace(string(output))) + + request := &struct { + Name string + Image string + Region string + Size string + VPCUUID string `json:"vpc_uuid"` + }{} + + err = json.Unmarshal(reqBody, request) + expect.NoError(err) + + expect.Equal("backup-policy-on-name", request.Name) + expect.Equal("a-test-image", request.Image) + expect.Equal("a-test-region", request.Region) + expect.Equal("a-test-size", request.Size) + expect.Equal("00000000-0000-4000-8000-000000000000", request.VPCUUID) + }) + }) }) const ( diff --git a/integration/droplet_supported_backup_policies_list_test.go b/integration/droplet_supported_backup_policies_list_test.go new file mode 100644 index 000000000..603916f84 --- /dev/null +++ b/integration/droplet_supported_backup_policies_list_test.go @@ -0,0 +1,135 @@ +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/sclevine/spec" + "github.com/stretchr/testify/require" +) + +var _ = suite("compute/droplet/backup-policies/list-supported", func(t *testing.T, when spec.G, it spec.S) { + var ( + expect *require.Assertions + server *httptest.Server + configPath string + ) + + it.Before(func() { + expect = require.New(t) + + dir := t.TempDir() + + configPath = filepath.Join(dir, "config.yaml") + + err := os.WriteFile(configPath, []byte(dropletSupportedBackupPoliciesListConfig), 0644) + expect.NoError(err) + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/v2/droplets/backups/supported_policies": + auth := req.Header.Get("Authorization") + if auth != "Bearer special-broken" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte(dropletSupportedBackupPoliciesListResponse)) + default: + dump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatal("failed to dump request") + } + + t.Fatalf("received unknown request: %s", dump) + } + })) + }) + + it.After(func() { + err := os.RemoveAll(configPath) + expect.NoError(err) + }) + + when("all required flags are passed", func() { + it("list supported droplet backup policies", func() { + cmd := exec.Command(builtBinaryPath, + "-c", configPath, + "-u", server.URL, + "compute", + "droplet", + "backup-policies", + "list-supported", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err, fmt.Sprintf("received error output: %s", output)) + expect.Equal(strings.TrimSpace(dropletSupportedBackupPoliciesListOutput), strings.TrimSpace(string(output))) + }) + }) +}) + +const ( + dropletSupportedBackupPoliciesListConfig = ` +--- +access-token: special-broken +` + dropletSupportedBackupPoliciesListOutput = ` +Name Possible Window Starts Window Length Hours Retention Period Days Possible Days +weekly [0 4 8 12 16 20] 4 28 [SUN MON TUE WED THU FRI SAT] +daily [0 4 8 12 16 20] 4 7 [] +` + dropletSupportedBackupPoliciesListResponse = ` +{ + "supported_policies": [ + { + "name": "weekly", + "possible_window_starts": [ + 0, + 4, + 8, + 12, + 16, + 20 + ], + "window_length_hours": 4, + "retention_period_days": 28, + "possible_days": [ + "SUN", + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT" + ] + }, + { + "name": "daily", + "possible_window_starts": [ + 0, + 4, + 8, + 12, + 16, + 20 + ], + "window_length_hours": 4, + "retention_period_days": 7, + "possible_days": [] + } + ] +}` +)