diff --git a/cli/workspaceautostart.go b/cli/workspaceautostart.go new file mode 100644 index 0000000000..a5954e9251 --- /dev/null +++ b/cli/workspaceautostart.go @@ -0,0 +1,100 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/coder/coder/coderd/autostart/schedule" + "github.com/coder/coder/codersdk" +) + +const autostartDescriptionLong = `To have your workspace build automatically at a regular time you can enable autostart. +When enabling autostart, provide a schedule. This schedule is in cron format except only +the following fields are allowed: +- minute +- hour +- day of week + +For example, to start your workspace every weekday at 9.30 am, provide the schedule '30 9 1-5'.` + +func workspaceAutostart() *cobra.Command { + autostartCmd := &cobra.Command{ + Use: "autostart enable ", + Short: "schedule a workspace to automatically start at a regular time", + Long: autostartDescriptionLong, + Example: "coder workspaces autostart enable my-workspace '30 9 1-5'", + Hidden: true, // TODO(cian): un-hide when autostart scheduling implemented + } + + autostartCmd.AddCommand(workspaceAutostartEnable()) + autostartCmd.AddCommand(workspaceAutostartDisable()) + + return autostartCmd +} + +func workspaceAutostartEnable() *cobra.Command { + return &cobra.Command{ + Use: "enable ", + ValidArgsFunction: validArgsWorkspaceName, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return err + } + + validSchedule, err := schedule.Weekly(args[1]) + if err != nil { + return err + } + + err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: validSchedule.String(), + }) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically start at %s.\n\n", workspace.Name, validSchedule.Next(time.Now())) + + return nil + }, + } +} + +func workspaceAutostartDisable() *cobra.Command { + return &cobra.Command{ + Use: "disable ", + ValidArgsFunction: validArgsWorkspaceName, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return err + } + + err = client.UpdateWorkspaceAutostart(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: "", + }) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will no longer automatically start.\n\n", workspace.Name) + + return nil + }, + } +} diff --git a/cli/workspaceautostart_test.go b/cli/workspaceautostart_test.go new file mode 100644 index 0000000000..c7de625f91 --- /dev/null +++ b/cli/workspaceautostart_test.go @@ -0,0 +1,150 @@ +package cli_test + +import ( + "bytes" + "context" + "testing" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/stretchr/testify/require" +) + +func TestWorkspaceAutostart(t *testing.T) { + t.Parallel() + + t.Run("EnableDisableOK", func(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + sched = "CRON_TZ=Europe/Dublin 30 9 1-5" + stdoutBuf = &bytes.Buffer{} + ) + + cmd, root := clitest.New(t, "workspaces", "autostart", "enable", workspace.Name, sched) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err := cmd.Execute() + require.NoError(t, err, "unexpected error") + require.Contains(t, stdoutBuf.String(), "will automatically start at", "unexpected output") + + // Ensure autostart schedule updated + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Equal(t, sched, updated.AutostartSchedule, "expected autostart schedule to be set") + + // Disable schedule + cmd, root = clitest.New(t, "workspaces", "autostart", "disable", workspace.Name) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err = cmd.Execute() + require.NoError(t, err, "unexpected error") + require.Contains(t, stdoutBuf.String(), "will no longer automatically start", "unexpected output") + + // Ensure autostart schedule updated + updated, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Empty(t, updated.AutostartSchedule, "expected autostart schedule to not be set") + }) + + t.Run("Enable_NotFound", func(t *testing.T) { + t.Parallel() + + var ( + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + sched = "CRON_TZ=Europe/Dublin 30 9 1-5" + ) + + cmd, root := clitest.New(t, "workspaces", "autostart", "enable", "doesnotexist", sched) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") + }) + + t.Run("Disable_NotFound", func(t *testing.T) { + t.Parallel() + + var ( + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + ) + + cmd, root := clitest.New(t, "workspaces", "autostart", "disable", "doesnotexist") + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") + }) + + t.Run("Enable_InvalidSchedule", func(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + sched = "sdfasdfasdf asdf asdf" + ) + + cmd, root := clitest.New(t, "workspaces", "autostart", "enable", workspace.Name, sched) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "failed to parse int from sdfasdfasdf: strconv.Atoi:", "unexpected error") + + // Ensure nothing happened + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Empty(t, updated.AutostartSchedule, "expected autostart schedule to be empty") + }) + + t.Run("Enable_NoSchedule", func(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + ) + + cmd, root := clitest.New(t, "workspaces", "autostart", "enable", workspace.Name) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "accepts 2 arg(s), received 1", "unexpected error") + + // Ensure nothing happened + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Empty(t, updated.AutostartSchedule, "expected autostart schedule to be empty") + }) +} diff --git a/cli/workspaceautostop.go b/cli/workspaceautostop.go new file mode 100644 index 0000000000..b8d26d75d9 --- /dev/null +++ b/cli/workspaceautostop.go @@ -0,0 +1,100 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/coder/coder/coderd/autostart/schedule" + "github.com/coder/coder/codersdk" +) + +const autostopDescriptionLong = `To have your workspace stop automatically at a regular time you can enable autostop. +When enabling autostop, provide a schedule. This schedule is in cron format except only +the following fields are allowed: +- minute +- hour +- day of week + +For example, to stop your workspace every weekday at 5.30 pm, provide the schedule '30 17 1-5'.` + +func workspaceAutostop() *cobra.Command { + autostopCmd := &cobra.Command{ + Use: "autostop enable ", + Short: "schedule a workspace to automatically start at a regular time", + Long: autostopDescriptionLong, + Example: "coder workspaces autostop enable my-workspace '30 17 1-5'", + Hidden: true, // TODO(cian): un-hide when autostop scheduling implemented + } + + autostopCmd.AddCommand(workspaceAutostopEnable()) + autostopCmd.AddCommand(workspaceAutostopDisable()) + + return autostopCmd +} + +func workspaceAutostopEnable() *cobra.Command { + return &cobra.Command{ + Use: "enable ", + ValidArgsFunction: validArgsWorkspaceName, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return err + } + + validSchedule, err := schedule.Weekly(args[1]) + if err != nil { + return err + } + + err = client.UpdateWorkspaceAutostop(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{ + Schedule: validSchedule.String(), + }) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will automatically stop at %s.\n\n", workspace.Name, validSchedule.Next(time.Now())) + + return nil + }, + } +} + +func workspaceAutostopDisable() *cobra.Command { + return &cobra.Command{ + Use: "disable ", + ValidArgsFunction: validArgsWorkspaceName, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + + workspace, err := client.WorkspaceByName(cmd.Context(), codersdk.Me, args[0]) + if err != nil { + return err + } + + err = client.UpdateWorkspaceAutostop(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceAutostopRequest{ + Schedule: "", + }) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nThe %s workspace will no longer automatically stop.\n\n", workspace.Name) + + return nil + }, + } +} diff --git a/cli/workspaceautostop_test.go b/cli/workspaceautostop_test.go new file mode 100644 index 0000000000..780ea40cf3 --- /dev/null +++ b/cli/workspaceautostop_test.go @@ -0,0 +1,150 @@ +package cli_test + +import ( + "bytes" + "context" + "testing" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/stretchr/testify/require" +) + +func TestWorkspaceAutostop(t *testing.T) { + t.Parallel() + + t.Run("EnableDisableOK", func(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + sched = "CRON_TZ=Europe/Dublin 30 9 1-5" + stdoutBuf = &bytes.Buffer{} + ) + + cmd, root := clitest.New(t, "workspaces", "autostop", "enable", workspace.Name, sched) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err := cmd.Execute() + require.NoError(t, err, "unexpected error") + require.Contains(t, stdoutBuf.String(), "will automatically stop at", "unexpected output") + + // Ensure autostop schedule updated + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Equal(t, sched, updated.AutostopSchedule, "expected autostop schedule to be set") + + // Disable schedule + cmd, root = clitest.New(t, "workspaces", "autostop", "disable", workspace.Name) + clitest.SetupConfig(t, client, root) + cmd.SetOut(stdoutBuf) + + err = cmd.Execute() + require.NoError(t, err, "unexpected error") + require.Contains(t, stdoutBuf.String(), "will no longer automatically stop", "unexpected output") + + // Ensure autostop schedule updated + updated, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Empty(t, updated.AutostopSchedule, "expected autostop schedule to not be set") + }) + + t.Run("Enable_NotFound", func(t *testing.T) { + t.Parallel() + + var ( + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + sched = "CRON_TZ=Europe/Dublin 30 9 1-5" + ) + + cmd, root := clitest.New(t, "workspaces", "autostop", "enable", "doesnotexist", sched) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") + }) + + t.Run("Disable_NotFound", func(t *testing.T) { + t.Parallel() + + var ( + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + ) + + cmd, root := clitest.New(t, "workspaces", "autostop", "disable", "doesnotexist") + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "status code 404: no workspace found by name", "unexpected error") + }) + + t.Run("Enable_InvalidSchedule", func(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + sched = "sdfasdfasdf asdf asdf" + ) + + cmd, root := clitest.New(t, "workspaces", "autostop", "enable", workspace.Name, sched) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "failed to parse int from sdfasdfasdf: strconv.Atoi:", "unexpected error") + + // Ensure nothing happened + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Empty(t, updated.AutostopSchedule, "expected autostop schedule to be empty") + }) + + t.Run("Enable_NoSchedule", func(t *testing.T) { + t.Parallel() + + var ( + ctx = context.Background() + client = coderdtest.New(t, nil) + _ = coderdtest.NewProvisionerDaemon(t, client) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace = coderdtest.CreateWorkspace(t, client, codersdk.Me, project.ID) + ) + + cmd, root := clitest.New(t, "workspaces", "autostop", "enable", workspace.Name) + clitest.SetupConfig(t, client, root) + + err := cmd.Execute() + require.ErrorContains(t, err, "accepts 2 arg(s), received 1", "unexpected error") + + // Ensure nothing happened + updated, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Empty(t, updated.AutostopSchedule, "expected autostop schedule to be empty") + }) +} diff --git a/cli/workspaces.go b/cli/workspaces.go index 582c7a0041..8e350f31ee 100644 --- a/cli/workspaces.go +++ b/cli/workspaces.go @@ -22,6 +22,8 @@ func workspaces() *cobra.Command { cmd.AddCommand(workspaceStart()) cmd.AddCommand(ssh()) cmd.AddCommand(workspaceUpdate()) + cmd.AddCommand(workspaceAutostart()) + cmd.AddCommand(workspaceAutostop()) return cmd }