diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index ef2b7aa7ebcd2..8896c3217558f 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "regexp" "runtime" "slices" "strings" @@ -39,6 +40,8 @@ const ( // by tmpfs or other mounts. This assumes the container root filesystem is // read-write, which seems sensible for devcontainers. coderPathInsideContainer = "/.coder-agent/coder" + + maxAgentNameLength = 64 ) // API is responsible for container-related operations in the agent. @@ -583,10 +586,11 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code if dc.Container != nil { if !api.devcontainerNames[dc.Name] { // If the devcontainer name wasn't set via terraform, we - // use the containers friendly name as a fallback which - // will keep changing as the devcontainer is recreated. - // TODO(mafredri): Parse the container label (i.e. devcontainer.json) for customization. - dc.Name = safeFriendlyName(dc.Container.FriendlyName) + // will attempt to create an agent name based on the workspace + // folder's name. If it is not possible to generate a valid + // agent name based off of the folder name (i.e. no valid characters), + // we will instead fall back to using the container's friendly name. + dc.Name = safeAgentName(path.Base(filepath.ToSlash(dc.WorkspaceFolder)), dc.Container.FriendlyName) } } @@ -631,6 +635,38 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code api.containersErr = nil } +var consecutiveHyphenRegex = regexp.MustCompile("-+") + +// `safeAgentName` returns a safe agent name derived from a folder name, +// falling back to the container’s friendly name if needed. +func safeAgentName(name string, friendlyName string) string { + // Keep only ASCII letters and digits, replacing everything + // else with a hyphen. + var sb strings.Builder + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + _, _ = sb.WriteRune(r) + } else { + _, _ = sb.WriteRune('-') + } + } + + // Remove any consecutive hyphens, and then trim any leading + // and trailing hyphens. + name = consecutiveHyphenRegex.ReplaceAllString(sb.String(), "-") + name = strings.Trim(name, "-") + + // Ensure the name of the agent doesn't exceed the maximum agent + // name length. + name = name[:min(len(name), maxAgentNameLength)] + + if provisioner.AgentNameRegex.Match([]byte(name)) { + return name + } + + return safeFriendlyName(friendlyName) +} + // safeFriendlyName returns a API safe version of the container's // friendly name. // diff --git a/agent/agentcontainers/api_internal_test.go b/agent/agentcontainers/api_internal_test.go new file mode 100644 index 0000000000000..bda6371f63e5e --- /dev/null +++ b/agent/agentcontainers/api_internal_test.go @@ -0,0 +1,201 @@ +package agentcontainers + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/provisioner" +) + +func TestSafeAgentName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + folderName string + expected string + }{ + // Basic valid names + { + folderName: "simple", + expected: "simple", + }, + { + folderName: "with-hyphens", + expected: "with-hyphens", + }, + { + folderName: "123numbers", + expected: "123numbers", + }, + { + folderName: "mixed123", + expected: "mixed123", + }, + + // Names that need transformation + { + folderName: "With_Underscores", + expected: "with-underscores", + }, + { + folderName: "With Spaces", + expected: "with-spaces", + }, + { + folderName: "UPPERCASE", + expected: "uppercase", + }, + { + folderName: "Mixed_Case-Name", + expected: "mixed-case-name", + }, + + // Names with special characters that get replaced + { + folderName: "special@#$chars", + expected: "special-chars", + }, + { + folderName: "dots.and.more", + expected: "dots-and-more", + }, + { + folderName: "multiple___underscores", + expected: "multiple-underscores", + }, + { + folderName: "multiple---hyphens", + expected: "multiple-hyphens", + }, + + // Edge cases with leading/trailing special chars + { + folderName: "-leading-hyphen", + expected: "leading-hyphen", + }, + { + folderName: "trailing-hyphen-", + expected: "trailing-hyphen", + }, + { + folderName: "_leading_underscore", + expected: "leading-underscore", + }, + { + folderName: "trailing_underscore_", + expected: "trailing-underscore", + }, + { + folderName: "---multiple-leading", + expected: "multiple-leading", + }, + { + folderName: "trailing-multiple---", + expected: "trailing-multiple", + }, + + // Complex transformation cases + { + folderName: "___very---complex@@@name___", + expected: "very-complex-name", + }, + { + folderName: "my.project-folder_v2", + expected: "my-project-folder-v2", + }, + + // Empty and fallback cases - now correctly uses friendlyName fallback + { + folderName: "", + expected: "friendly-fallback", + }, + { + folderName: "---", + expected: "friendly-fallback", + }, + { + folderName: "___", + expected: "friendly-fallback", + }, + { + folderName: "@#$", + expected: "friendly-fallback", + }, + + // Additional edge cases + { + folderName: "a", + expected: "a", + }, + { + folderName: "1", + expected: "1", + }, + { + folderName: "a1b2c3", + expected: "a1b2c3", + }, + { + folderName: "CamelCase", + expected: "camelcase", + }, + { + folderName: "snake_case_name", + expected: "snake-case-name", + }, + { + folderName: "kebab-case-name", + expected: "kebab-case-name", + }, + { + folderName: "mix3d_C4s3-N4m3", + expected: "mix3d-c4s3-n4m3", + }, + { + folderName: "123-456-789", + expected: "123-456-789", + }, + { + folderName: "abc123def456", + expected: "abc123def456", + }, + { + folderName: " spaces everywhere ", + expected: "spaces-everywhere", + }, + { + folderName: "unicode-café-naïve", + expected: "unicode-caf-na-ve", + }, + { + folderName: "path/with/slashes", + expected: "path-with-slashes", + }, + { + folderName: "file.tar.gz", + expected: "file-tar-gz", + }, + { + folderName: "version-1.2.3-alpha", + expected: "version-1-2-3-alpha", + }, + + // Truncation test for names exceeding 64 characters + { + folderName: "this-is-a-very-long-folder-name-that-exceeds-sixty-four-characters-limit-and-should-be-truncated", + expected: "this-is-a-very-long-folder-name-that-exceeds-sixty-four-characte", + }, + } + + for _, tt := range tests { + t.Run(tt.folderName, func(t *testing.T) { + t.Parallel() + name := safeAgentName(tt.folderName, "friendly-fallback") + + assert.Equal(t, tt.expected, name) + assert.True(t, provisioner.AgentNameRegex.Match([]byte(name))) + }) + } +} diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 4e3e9e4077cd7..a59a3bfd6731e 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -897,8 +897,8 @@ func TestAPI(t *testing.T) { FriendlyName: "project1-container", Running: true, Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project", - agentcontainers.DevcontainerConfigFileLabel: "/workspace/project/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project1", + agentcontainers.DevcontainerConfigFileLabel: "/workspace/project1/.devcontainer/devcontainer.json", }, }, { @@ -906,8 +906,8 @@ func TestAPI(t *testing.T) { FriendlyName: "project2-container", Running: true, Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/home/user/project", - agentcontainers.DevcontainerConfigFileLabel: "/home/user/project/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/home/user/project2", + agentcontainers.DevcontainerConfigFileLabel: "/home/user/project2/.devcontainer/devcontainer.json", }, }, { @@ -915,8 +915,8 @@ func TestAPI(t *testing.T) { FriendlyName: "project3-container", Running: true, Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/var/lib/project", - agentcontainers.DevcontainerConfigFileLabel: "/var/lib/project/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/var/lib/project3", + agentcontainers.DevcontainerConfigFileLabel: "/var/lib/project3/.devcontainer/devcontainer.json", }, }, }, @@ -1326,7 +1326,7 @@ func TestAPI(t *testing.T) { // Allow initial agent creation and injection to succeed. testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil) testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error { - assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container") + assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder") assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace") assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user") assert.Contains(t, envs, "CODER_URL=test-subagent-url") @@ -1349,7 +1349,7 @@ func TestAPI(t *testing.T) { // Verify agent was created. require.Len(t, fakeSAC.created, 1) - assert.Equal(t, "test-container", fakeSAC.created[0].Name) + assert.Equal(t, "coder", fakeSAC.created[0].Name) assert.Equal(t, "/workspaces/coder", fakeSAC.created[0].Directory) assert.Len(t, fakeSAC.deleted, 0) @@ -1405,7 +1405,7 @@ func TestAPI(t *testing.T) { WaitStartLoop: for { // Agent reinjection will succeed and we will not re-create the - // agent, nor re-probe pwd. + // agent. mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{testContainer}, }, nil).Times(1) // 1 update. @@ -1468,7 +1468,7 @@ func TestAPI(t *testing.T) { // Expect the agent to be recreated. testutil.RequireSend(ctx, t, fakeSAC.createErrC, nil) testutil.RequireSend(ctx, t, fakeDCCLI.readConfigErrC, func(envs []string) error { - assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=test-container") + assert.Contains(t, envs, "CODER_WORKSPACE_AGENT_NAME=coder") assert.Contains(t, envs, "CODER_WORKSPACE_NAME=test-workspace") assert.Contains(t, envs, "CODER_WORKSPACE_OWNER_NAME=test-user") assert.Contains(t, envs, "CODER_URL=test-subagent-url") @@ -1910,8 +1910,8 @@ func TestAPI(t *testing.T) { Running: true, CreatedAt: time.Now(), Labels: map[string]string{ - agentcontainers.DevcontainerLocalFolderLabel: "/workspaces", - agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/coder", + agentcontainers.DevcontainerConfigFileLabel: "/workspaces/coder/.devcontainer/devcontainer.json", }, } ) @@ -1953,13 +1953,13 @@ func TestAPI(t *testing.T) { testutil.RequireSend(ctx, t, fSAC.createErrC, nil) testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error { // We expect the wrong workspace agent name passed in first. - assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=test-container") + assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=coder") return nil }) testutil.RequireSend(ctx, t, fDCCLI.readConfigErrC, func(env []string) error { // We then expect the agent name passed here to have been read from the config. assert.Contains(t, env, "CODER_WORKSPACE_AGENT_NAME=custom-name") - assert.NotContains(t, env, "CODER_WORKSPACE_AGENT_NAME=test-container") + assert.NotContains(t, env, "CODER_WORKSPACE_AGENT_NAME=coder") return nil }) diff --git a/cli/delete_test.go b/cli/delete_test.go index ecd1c6996df1d..a48ca98627f65 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "io" + "net/http" "testing" "time" @@ -60,28 +61,35 @@ func TestDelete(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID) + template := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, workspace.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitShort) inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan") + clitest.SetupConfig(t, templateAdmin, root) - //nolint:gocritic // Deleting orphaned workspaces requires an admin. - clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) inv.Stderr = pty.Output() go func() { defer close(doneChan) - err := inv.Run() + err := inv.WithContext(ctx).Run() // When running with the race detector on, we sometimes get an EOF. if err != nil { assert.ErrorIs(t, err, io.EOF) } }() pty.ExpectMatch("has been deleted") - <-doneChan + testutil.TryReceive(ctx, t, doneChan) + + _, err := client.Workspace(ctx, workspace.ID) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusGone, cerr.StatusCode()) }) // Super orphaned, as the workspace doesn't even have a user. diff --git a/cli/start_test.go b/cli/start_test.go index f64a3f0646303..ec5f0b4735b39 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -358,7 +358,7 @@ func TestStartAutoUpdate(t *testing.T) { coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) if c.Cmd == "start" { - coderdtest.MustTransitionWorkspace(t, member, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, member, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) } version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(stringRichParameters), func(ctvr *codersdk.CreateTemplateVersionRequest) { ctvr.TemplateID = template.ID diff --git a/cli/stop.go b/cli/stop.go index 218c42061db10..ef4813ff0a1a0 100644 --- a/cli/stop.go +++ b/cli/stop.go @@ -37,32 +37,11 @@ func (r *RootCmd) stop() *serpent.Command { if err != nil { return err } - if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobPending { - // cliutil.WarnMatchedProvisioners also checks if the job is pending - // but we still want to avoid users spamming multiple builds that will - // not be picked up. - cliui.Warn(inv.Stderr, "The workspace is already stopping!") - cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) - if _, err := cliui.Prompt(inv, cliui.PromptOptions{ - Text: "Enqueue another stop?", - IsConfirm: true, - Default: cliui.ConfirmNo, - }); err != nil { - return err - } - } - wbr := codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionStop, - } - if bflags.provisionerLogDebug { - wbr.LogLevel = codersdk.ProvisionerLogLevelDebug - } - build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr) + build, err := stopWorkspace(inv, client, workspace, bflags) if err != nil { return err } - cliutil.WarnMatchedProvisioners(inv.Stderr, build.MatchedProvisioners, build.Job) err = cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID) if err != nil { @@ -71,8 +50,8 @@ func (r *RootCmd) stop() *serpent.Command { _, _ = fmt.Fprintf( inv.Stdout, - "\nThe %s workspace has been stopped at %s!\n", cliui.Keyword(workspace.Name), - + "\nThe %s workspace has been stopped at %s!\n", + cliui.Keyword(workspace.Name), cliui.Timestamp(time.Now()), ) return nil @@ -82,3 +61,27 @@ func (r *RootCmd) stop() *serpent.Command { return cmd } + +func stopWorkspace(inv *serpent.Invocation, client *codersdk.Client, workspace codersdk.Workspace, bflags buildFlags) (codersdk.WorkspaceBuild, error) { + if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobPending { + // cliutil.WarnMatchedProvisioners also checks if the job is pending + // but we still want to avoid users spamming multiple builds that will + // not be picked up. + cliui.Warn(inv.Stderr, "The workspace is already stopping!") + cliutil.WarnMatchedProvisioners(inv.Stderr, workspace.LatestBuild.MatchedProvisioners, workspace.LatestBuild.Job) + if _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Enqueue another stop?", + IsConfirm: true, + Default: cliui.ConfirmNo, + }); err != nil { + return codersdk.WorkspaceBuild{}, err + } + } + wbr := codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStop, + } + if bflags.provisionerLogDebug { + wbr.LogLevel = codersdk.ProvisionerLogLevelDebug + } + return client.CreateWorkspaceBuild(inv.Context(), workspace.ID, wbr) +} diff --git a/cli/testdata/coder_--help.golden b/cli/testdata/coder_--help.golden index 1b2dbcf25056b..09dd4c3bce3a5 100644 --- a/cli/testdata/coder_--help.golden +++ b/cli/testdata/coder_--help.golden @@ -57,7 +57,8 @@ SUBCOMMANDS: tokens Manage personal access tokens unfavorite Remove a workspace from your favorites update Will update and start a given workspace if it is out of - date + date. If the workspace is already running, it will be + stopped first. users Manage users version Show coder version whoami Fetch authenticated user info for Coder deployment diff --git a/cli/testdata/coder_update_--help.golden b/cli/testdata/coder_update_--help.golden index 501447add29a7..b7bd7c48ed1e0 100644 --- a/cli/testdata/coder_update_--help.golden +++ b/cli/testdata/coder_update_--help.golden @@ -3,7 +3,8 @@ coder v0.0.0-devel USAGE: coder update [flags] - Will update and start a given workspace if it is out of date + Will update and start a given workspace if it is out of date. If the workspace + is already running, it will be stopped first. Use --always-prompt to change the parameter values of the workspace. diff --git a/cli/update.go b/cli/update.go index cf73992ea7ba4..21f090508d193 100644 --- a/cli/update.go +++ b/cli/update.go @@ -5,6 +5,7 @@ import ( "golang.org/x/xerrors" + "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" ) @@ -18,7 +19,7 @@ func (r *RootCmd) update() *serpent.Command { cmd := &serpent.Command{ Annotations: workspaceCommand, Use: "update ", - Short: "Will update and start a given workspace if it is out of date", + Short: "Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first.", Long: "Use --always-prompt to change the parameter values of the workspace.", Middleware: serpent.Chain( serpent.RequireNArgs(1), @@ -34,6 +35,20 @@ func (r *RootCmd) update() *serpent.Command { return nil } + // #17840: If the workspace is already running, we will stop it before + // updating. Simply performing a new start transition may not work if the + // template specifies ignore_changes. + if workspace.LatestBuild.Transition == codersdk.WorkspaceTransitionStart { + build, err := stopWorkspace(inv, client, workspace, bflags) + if err != nil { + return xerrors.Errorf("stop workspace: %w", err) + } + // Wait for the stop to complete. + if err := cliui.WorkspaceBuild(inv.Context(), inv.Stdout, client, build.ID); err != nil { + return xerrors.Errorf("wait for stop: %w", err) + } + } + build, err := startWorkspace(inv, client, workspace, parameterFlags, bflags, WorkspaceUpdate) if err != nil { return xerrors.Errorf("start workspace: %w", err) diff --git a/cli/update_test.go b/cli/update_test.go index 367a8196aa499..7a7480353c01d 100644 --- a/cli/update_test.go +++ b/cli/update_test.go @@ -34,28 +34,87 @@ func TestUpdate(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() + // Given: a workspace exists on the latest template version. client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) - member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) - inv, root := clitest.New(t, "create", - "my-workspace", - "--template", template.Name, - "-y", - ) + ws := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.Name = "my-workspace" + }) + require.False(t, ws.Outdated, "newly created workspace with active template version must not be outdated") + + // Given: the template version is updated + version2 := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionApply: echo.ApplyComplete, + ProvisionPlan: echo.PlanComplete, + }, template.ID) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) + + ctx := testutil.Context(t, testutil.WaitShort) + err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: version2.ID, + }) + require.NoError(t, err, "failed to update active template version") + + // Then: the workspace is marked as 'outdated' + ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err, "member failed to get workspace they themselves own") + require.True(t, ws.Outdated, "workspace must be outdated after template version update") + + // When: the workspace is updated + inv, root := clitest.New(t, "update", ws.Name) clitest.SetupConfig(t, member, root) - err := inv.Run() - require.NoError(t, err) + err = inv.Run() + require.NoError(t, err, "update command failed") + + // Then: the workspace is no longer 'outdated' + ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err, "member failed to get workspace they themselves own after update") + require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String(), "workspace must have latest template version after update") + require.False(t, ws.Outdated, "workspace must not be outdated after update") + + // Then: the workspace must have been started with the new template version + require.Equal(t, int32(3), ws.LatestBuild.BuildNumber, "workspace must have 3 builds after update") + require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "latest build must be a start transition") + + // Then: the previous workspace build must be a stop transition with the old + // template version. + // This is important to ensure that the workspace resources are recreated + // correctly. Simply running a start transition with the new template + // version may not recreate resources that were changed in the new + // template version. This can happen, for example, if a user specifies + // ignore_changes in the template. + prevBuild, err := member.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx, codersdk.Me, ws.Name, "2") + require.NoError(t, err, "failed to get previous workspace build") + require.Equal(t, codersdk.WorkspaceTransitionStop, prevBuild.Transition, "previous build must be a stop transition") + require.Equal(t, version1.ID.String(), prevBuild.TemplateVersionID.String(), "previous build must have the old template version") + }) - ws, err := client.WorkspaceByOwnerAndName(context.Background(), memberUser.Username, "my-workspace", codersdk.WorkspaceOptions{}) - require.NoError(t, err) - require.Equal(t, version1.ID.String(), ws.LatestBuild.TemplateVersionID.String()) + t.Run("Stopped", func(t *testing.T) { + t.Parallel() + + // Given: a workspace exists on the latest template version. + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID) + ws := coderdtest.CreateWorkspace(t, member, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.Name = "my-workspace" + }) + require.False(t, ws.Outdated, "newly created workspace with active template version must not be outdated") + + // Given: the template version is updated version2 := coderdtest.UpdateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, ProvisionApply: echo.ApplyComplete, @@ -63,20 +122,37 @@ func TestUpdate(t *testing.T) { }, template.ID) _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) - err = client.UpdateActiveTemplateVersion(context.Background(), template.ID, codersdk.UpdateActiveTemplateVersion{ + ctx := testutil.Context(t, testutil.WaitShort) + err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ ID: version2.ID, }) - require.NoError(t, err) + require.NoError(t, err, "failed to update active template version") + + // Given: the workspace is in a stopped state. + coderdtest.MustTransitionWorkspace(t, member, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) - inv, root = clitest.New(t, "update", ws.Name) + // Then: the workspace is marked as 'outdated' + ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err, "member failed to get workspace they themselves own") + require.True(t, ws.Outdated, "workspace must be outdated after template version update") + + // When: the workspace is updated + inv, root := clitest.New(t, "update", ws.Name) clitest.SetupConfig(t, member, root) err = inv.Run() - require.NoError(t, err) - - ws, err = member.WorkspaceByOwnerAndName(context.Background(), memberUser.Username, "my-workspace", codersdk.WorkspaceOptions{}) - require.NoError(t, err) - require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String()) + require.NoError(t, err, "update command failed") + + // Then: the workspace is no longer 'outdated' + ws, err = member.WorkspaceByOwnerAndName(ctx, codersdk.Me, "my-workspace", codersdk.WorkspaceOptions{}) + require.NoError(t, err, "member failed to get workspace they themselves own after update") + require.Equal(t, version2.ID.String(), ws.LatestBuild.TemplateVersionID.String(), "workspace must have latest template version after update") + require.False(t, ws.Outdated, "workspace must not be outdated after update") + + // Then: the workspace must have been started with the new template version + require.Equal(t, codersdk.WorkspaceTransitionStart, ws.LatestBuild.Transition, "latest build must be a start transition") + // Then: we expect 3 builds, as we manually stopped the workspace. + require.Equal(t, int32(3), ws.LatestBuild.BuildNumber, "workspace must have 3 builds after update") }) } diff --git a/coderd/agentapi/apps.go b/coderd/agentapi/apps.go index 956e154e89d0d..89c1a873d6310 100644 --- a/coderd/agentapi/apps.go +++ b/coderd/agentapi/apps.go @@ -92,7 +92,7 @@ func (a *AppsAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.Bat Health: app.Health, }) if err != nil { - return nil, xerrors.Errorf("update workspace app health for app %q (%q): %w", err, app.ID, app.Slug) + return nil, xerrors.Errorf("update workspace app health for app %q (%q): %w", app.ID, app.Slug, err) } } diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 65af911122aba..3bca6856534fa 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -47,7 +47,7 @@ func TestExecutorAutostartOK(t *testing.T) { }) ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks after the scheduled time go func() { @@ -105,7 +105,7 @@ func TestMultipleLifecycleExecutors(t *testing.T) { ) // Have the workspace stopped so we can perform an autostart - workspace = coderdtest.MustTransitionWorkspace(t, clientA, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, clientA, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Get both clients to perform a lifecycle execution tick next := sched.Next(workspace.LatestBuild.CreatedAt) @@ -203,7 +203,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { ) // Given: workspace is stopped workspace = coderdtest.MustTransitionWorkspace( - t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) orgs, err := client.OrganizationsByUser(ctx, workspace.OwnerID.String()) require.NoError(t, err) @@ -344,7 +344,7 @@ func TestExecutorAutostartNotEnabled(t *testing.T) { require.Empty(t, workspace.AutostartSchedule) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks way into the future go func() { @@ -384,7 +384,7 @@ func TestExecutorAutostartUserSuspended(t *testing.T) { workspace = coderdtest.MustWorkspace(t, userClient, workspace.ID) // Given: workspace is stopped, and the user is suspended. - workspace = coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) ctx := testutil.Context(t, testutil.WaitShort) @@ -507,7 +507,7 @@ func TestExecutorAutostopAlreadyStopped(t *testing.T) { ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks past the TTL go func() { @@ -578,7 +578,7 @@ func TestExecutorWorkspaceDeleted(t *testing.T) { ) // Given: workspace is deleted - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionDelete) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete) // When: the autobuild executor ticks go func() { @@ -767,7 +767,7 @@ func TestExecutorAutostartMultipleOK(t *testing.T) { }) ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks past the scheduled time go func() { @@ -832,7 +832,7 @@ func TestExecutorAutostartWithParameters(t *testing.T) { }) ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks after the scheduled time go func() { @@ -882,7 +882,7 @@ func TestExecutorAutostartTemplateDisabled(t *testing.T) { }) ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks before the next scheduled time go func() { @@ -1001,7 +1001,7 @@ func TestExecutorRequireActiveVersion(t *testing.T) { cwr.AutostartSchedule = ptr.Ref(sched.String()) }) _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, ownerClient, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, memberClient, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { + ws = coderdtest.MustTransitionWorkspace(t, memberClient, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { req.TemplateVersionID = inactiveVersion.ID }) require.Equal(t, inactiveVersion.ID, ws.LatestBuild.TemplateVersionID) @@ -1159,7 +1159,7 @@ func TestNotifications(t *testing.T) { coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) // Stop workspace - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) // Wait for workspace to become dormant diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a8f444c8f632e..b509db10e3eba 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -1245,16 +1245,16 @@ func CreateWorkspace(t testing.TB, client *codersdk.Client, templateID uuid.UUID } // TransitionWorkspace is a convenience method for transitioning a workspace from one state to another. -func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition, muts ...func(req *codersdk.CreateWorkspaceBuildRequest)) codersdk.Workspace { +func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to codersdk.WorkspaceTransition, muts ...func(req *codersdk.CreateWorkspaceBuildRequest)) codersdk.Workspace { t.Helper() ctx := context.Background() workspace, err := client.Workspace(ctx, workspaceID) require.NoError(t, err, "unexpected error fetching workspace") - require.Equal(t, workspace.LatestBuild.Transition, codersdk.WorkspaceTransition(from), "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition) + require.Equal(t, workspace.LatestBuild.Transition, from, "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition) req := codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - Transition: codersdk.WorkspaceTransition(to), + Transition: to, } for _, mut := range muts { @@ -1267,7 +1267,7 @@ func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID _ = AwaitWorkspaceBuildJobCompleted(t, client, build.ID) updated := MustWorkspace(t, client, workspace.ID) - require.Equal(t, codersdk.WorkspaceTransition(to), updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition) + require.Equal(t, to, updated.LatestBuild.Transition, "expected workspace to be in state %s but got %s", to, updated.LatestBuild.Transition) return updated } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 25b44e2930892..f4403d2f50c75 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -4497,7 +4497,8 @@ func (q *FakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.Provi defer q.mutex.RUnlock() if len(q.provisionerDaemons) == 0 { - return nil, sql.ErrNoRows + // Returning err=nil here for consistency with real querier + return []database.ProvisionerDaemon{}, nil } // copy the data so that the caller can't manipulate any data inside dbmem // after returning diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 78dcd4e993b9f..a8f7c63a586fe 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -2595,8 +2595,19 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. openIn = database.WorkspaceAppOpenInSlimWindow } + var appID string + if app.Id == "" || app.Id == uuid.Nil.String() { + appID = uuid.NewString() + } else { + appID = app.Id + } + id, err := uuid.Parse(appID) + if err != nil { + return xerrors.Errorf("parse app uuid: %w", err) + } + dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{ - ID: uuid.New(), + ID: id, CreatedAt: dbtime.Now(), AgentID: dbAgent.ID, Slug: slug, diff --git a/coderd/workspaceapps/apptest/apptest.go b/coderd/workspaceapps/apptest/apptest.go index 050e5d2fb9a6e..d0f3acda77278 100644 --- a/coderd/workspaceapps/apptest/apptest.go +++ b/coderd/workspaceapps/apptest/apptest.go @@ -27,7 +27,6 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/jwtutils" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/workspaceapps" @@ -1820,7 +1819,7 @@ func Run(t *testing.T, appHostIsPrimary bool, factory DeploymentFactory) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _ = coderdtest.MustTransitionWorkspace(t, appDetails.SDKClient, appDetails.Workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + _ = coderdtest.MustTransitionWorkspace(t, appDetails.SDKClient, appDetails.Workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) u := appDetails.PathAppURL(appDetails.Apps.Owner) resp, err := appDetails.AppClient(t).Request(ctx, http.MethodGet, u.String(), nil) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 74946d46dcd9f..15614f84b4f70 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -3,6 +3,7 @@ package coderd import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "math" @@ -433,20 +434,56 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } + var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow if provisionerJob != nil { + queuePos.ProvisionerJob = *provisionerJob + queuePos.QueuePosition = 0 if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil { // Client probably doesn't care about this error, so just log it. api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) } + + // We may need to complete the audit if wsbuilder determined that + // no provisioner could handle an orphan-delete job and completed it. + if createBuild.Orphan && createBuild.Transition == codersdk.WorkspaceTransitionDelete && provisionerJob.CompletedAt.Valid { + api.Logger.Warn(ctx, "orphan delete handled by wsbuilder due to no eligible provisioners", + slog.F("workspace_id", workspace.ID), + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("provisioner_job_id", provisionerJob.ID), + ) + buildResourceInfo := audit.AdditionalFields{ + WorkspaceName: workspace.Name, + BuildNumber: strconv.Itoa(int(workspaceBuild.BuildNumber)), + BuildReason: workspaceBuild.Reason, + WorkspaceID: workspace.ID, + WorkspaceOwner: workspace.OwnerName, + } + briBytes, err := json.Marshal(buildResourceInfo) + if err != nil { + api.Logger.Error(ctx, "failed to marshal build resource info for audit", slog.Error(err)) + } + auditor := api.Auditor.Load() + bag := audit.BaggageFromContext(ctx) + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceBuild]{ + Audit: *auditor, + Log: api.Logger, + UserID: provisionerJob.InitiatorID, + OrganizationID: workspace.OrganizationID, + RequestID: provisionerJob.ID, + IP: bag.IP, + Action: database.AuditActionDelete, + Old: previousWorkspaceBuild, + New: *workspaceBuild, + Status: http.StatusOK, + AdditionalFields: briBytes, + }) + } } apiBuild, err := api.convertWorkspaceBuild( *workspaceBuild, workspace, - database.GetProvisionerJobsByIDsWithQueuePositionRow{ - ProvisionerJob: *provisionerJob, - QueuePosition: 0, - }, + queuePos, []database.WorkspaceResource{}, []database.WorkspaceResourceMetadatum{}, []database.WorkspaceAgent{}, diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index ac33c9e92c4f7..b9d32a00b139a 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "bytes" "context" "database/sql" "errors" @@ -25,6 +26,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -371,42 +373,174 @@ func TestWorkspaceBuildsProvisionerState(t *testing.T) { t.Run("Orphan", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - first := coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + t.Run("WithoutDelete", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Trying to orphan without delete transition fails. + _, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionStart, + Orphan: true, + }) + require.Error(t, err, "Orphan is only permitted when deleting a workspace.") + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + t.Run("WithState", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Providing both state and orphan fails. + _, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + ProvisionerState: []byte(" "), + Orphan: true, + }) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) - // Providing both state and orphan fails. - _, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - Transition: codersdk.WorkspaceTransitionDelete, - ProvisionerState: []byte(" "), - Orphan: true, + t.Run("NoPermission", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + member, memberUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: memberUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Trying to orphan without being a template admin fails. + _, err := member.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusForbidden, cerr.StatusCode()) }) - require.Error(t, err) - cerr := coderdtest.SDKError(t, err) - require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) - // Regular orphan operation succeeds. - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - Transition: codersdk.WorkspaceTransitionDelete, - Orphan: true, + t.Run("OK", func(t *testing.T) { + // Include a provisioner so that we can test that provisionerdserver + // performs deletion. + auditor := audit.NewMock() + client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + // This is a valid zip file. Without this the job will fail to complete. + // TODO: add this to dbfake by default. + zipBytes := make([]byte, 22) + zipBytes[0] = 80 + zipBytes[1] = 75 + zipBytes[2] = 0o5 + zipBytes[3] = 0o6 + uploadRes, err := client.Upload(ctx, codersdk.ContentTypeZip, bytes.NewReader(zipBytes)) + require.NoError(t, err) + + tv := dbfake.TemplateVersion(t, store). + FileID(uploadRes.ID). + Seed(database.TemplateVersion{ + OrganizationID: first.OrganizationID, + CreatedBy: templateAdminUser.ID, + }). + Do() + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + TemplateID: tv.Template.ID, + }).Do() + + auditor.ResetLogs() + // Regular orphan operation succeeds. + build, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Validate that the deletion was audited. + require.True(t, auditor.Contains(t, database.AuditLog{ + ResourceID: build.ID, + Action: database.AuditActionDelete, + })) }) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) - _, err = client.Workspace(ctx, workspace.ID) - require.Error(t, err) - require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode()) + t.Run("NoProvisioners", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor}) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + // nolint:gocritic // For testing + daemons, err := store.GetProvisionerDaemons(dbauthz.AsSystemReadProvisionerDaemons(ctx)) + require.NoError(t, err) + require.Empty(t, daemons, "Provisioner daemons should be empty for this test") + + // Orphan deletion still succeeds despite no provisioners being available. + build, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceTransitionDelete, build.Transition) + require.Equal(t, codersdk.ProvisionerJobSucceeded, build.Job.Status) + require.Empty(t, build.Job.Error) + + ws, err := client.Workspace(ctx, r.Workspace.ID) + require.Empty(t, ws) + require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode()) + + // Validate that the deletion was audited. + require.True(t, auditor.Contains(t, database.AuditLog{ + ResourceID: build.ID, + Action: database.AuditActionDelete, + })) + }) }) } @@ -585,7 +719,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) // Create a workspace using this template workspace := coderdtest.CreateWorkspace(t, userClient, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Create a new version of the template newVersion := coderdtest.CreateTemplateVersion(t, templateAdminClient, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { @@ -598,7 +732,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) cwbr.TemplateVersionID = newVersion.ID }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Create the workspace build _again_. We are doing this to // ensure we do not create _another_ notification. This is @@ -610,7 +744,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) cwbr.TemplateVersionID = newVersion.ID }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // We're going to have two notifications (one for the first user and one for the template admin) // By ensuring we only have these two, we are sure the second build didn't trigger more @@ -659,7 +793,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) // Create a workspace using this template workspace := coderdtest.CreateWorkspace(t, userClient, template.ID) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Create a new version of the template newVersion := coderdtest.CreateTemplateVersion(t, templateAdminClient, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { @@ -675,7 +809,7 @@ func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) }) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) - coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Ensure we receive only 1 workspace manually updated notification and to the right user sent := notify.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceManuallyUpdated)) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 1d02622d455e8..4f48136abb3ee 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3992,7 +3992,7 @@ func TestWorkspaceDormant(t *testing.T) { require.NoError(t, err) // Should be able to stop a workspace while it is dormant. - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Should not be able to start a workspace while it is dormant. _, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ @@ -4005,7 +4005,7 @@ func TestWorkspaceDormant(t *testing.T) { Dormant: false, }) require.NoError(t, err) - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) + coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) }) } diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index b52a20ac1e9db..a996d1594a50d 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -464,6 +464,50 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object return BuildError{http.StatusInternalServerError, "get workspace build", err} } + // If the requestor is trying to orphan-delete a workspace and there are no + // provisioners available, we should complete the build and mark the + // workspace as deleted ourselves. + // There are cases where tagged provisioner daemons have been decommissioned + // without deleting the relevant workspaces, and without any provisioners + // available these workspaces cannot be deleted. + // Orphan-deleting a workspace sends an empty state to Terraform, which means + // it won't actually delete anything. So we actually don't need to execute a + // provisioner job at all for an orphan delete, but deleting without a workspace + // build or provisioner job would result in no audit log entry, which is a deal-breaker. + hasActiveEligibleProvisioner := false + for _, pd := range provisionerDaemons { + age := now.Sub(pd.ProvisionerDaemon.LastSeenAt.Time) + if age <= provisionerdserver.StaleInterval { + hasActiveEligibleProvisioner = true + break + } + } + if b.state.orphan && !hasActiveEligibleProvisioner { + // nolint: gocritic // At this moment, we are pretending to be provisionerd. + if err := store.UpdateProvisionerJobWithCompleteWithStartedAtByID(dbauthz.AsProvisionerd(b.ctx), database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams{ + CompletedAt: sql.NullTime{Valid: true, Time: now}, + Error: sql.NullString{Valid: false}, + ErrorCode: sql.NullString{Valid: false}, + ID: provisionerJob.ID, + StartedAt: sql.NullTime{Valid: true, Time: now}, + UpdatedAt: now, + }); err != nil { + return BuildError{http.StatusInternalServerError, "mark orphan-delete provisioner job as completed", err} + } + + // Re-fetch the completed provisioner job. + if pj, err := store.GetProvisionerJobByID(b.ctx, provisionerJob.ID); err == nil { + provisionerJob = pj + } + + if err := store.UpdateWorkspaceDeletedByID(b.ctx, database.UpdateWorkspaceDeletedByIDParams{ + ID: b.workspace.ID, + Deleted: true, + }); err != nil { + return BuildError{http.StatusInternalServerError, "mark workspace as deleted", err} + } + } + return nil }, nil) if err != nil { diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index 58999a33e6e5e..f6b4756455f14 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -839,6 +839,147 @@ func TestWorkspaceBuildWithPreset(t *testing.T) { req.NoError(err) } +func TestWorkspaceBuildDeleteOrphan(t *testing.T) { + t.Parallel() + + t.Run("WithActiveProvisioners", func(t *testing.T) { + t.Parallel() + req := require.New(t) + asrt := assert.New(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var buildID uuid.UUID + + mDB := expectDB(t, + // Inputs + withTemplate, + withInactiveVersion(nil), + withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), + withRichParameters(nil), + withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{{ + JobID: inactiveJobID, + ProvisionerDaemon: database.ProvisionerDaemon{ + LastSeenAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + }, + }}), + + // Outputs + expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + asrt.Equal(userID, job.InitiatorID) + asrt.Equal(inactiveFileID, job.FileID) + input := provisionerdserver.WorkspaceProvisionJob{} + err := json.Unmarshal(job.Input, &input) + req.NoError(err) + // store build ID for later + buildID = input.WorkspaceBuildID + }), + + withInTx, + expectBuild(func(bld database.InsertWorkspaceBuildParams) { + asrt.Equal(inactiveVersionID, bld.TemplateVersionID) + asrt.Equal(workspaceID, bld.WorkspaceID) + asrt.Equal(int32(2), bld.BuildNumber) + asrt.Empty(string(bld.ProvisionerState)) + asrt.Equal(userID, bld.InitiatorID) + asrt.Equal(database.WorkspaceTransitionDelete, bld.Transition) + asrt.Equal(database.BuildReasonInitiator, bld.Reason) + asrt.Equal(buildID, bld.ID) + }), + withBuild, + expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) { + asrt.Equal(buildID, params.WorkspaceBuildID) + asrt.Empty(params.Name) + asrt.Empty(params.Value) + }), + ) + + ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} + uut := wsbuilder.New(ws, database.WorkspaceTransitionDelete).Orphan() + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + req.NoError(err) + }) + + t.Run("NoActiveProvisioners", func(t *testing.T) { + t.Parallel() + req := require.New(t) + asrt := assert.New(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var buildID uuid.UUID + var jobID uuid.UUID + + mDB := expectDB(t, + // Inputs + withTemplate, + withInactiveVersion(nil), + withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), + withRichParameters(nil), + withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), + + // Outputs + expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + asrt.Equal(userID, job.InitiatorID) + asrt.Equal(inactiveFileID, job.FileID) + input := provisionerdserver.WorkspaceProvisionJob{} + err := json.Unmarshal(job.Input, &input) + req.NoError(err) + // store build ID for later + buildID = input.WorkspaceBuildID + // store job ID for later + jobID = job.ID + }), + + withInTx, + expectBuild(func(bld database.InsertWorkspaceBuildParams) { + asrt.Equal(inactiveVersionID, bld.TemplateVersionID) + asrt.Equal(workspaceID, bld.WorkspaceID) + asrt.Equal(int32(2), bld.BuildNumber) + asrt.Empty(string(bld.ProvisionerState)) + asrt.Equal(userID, bld.InitiatorID) + asrt.Equal(database.WorkspaceTransitionDelete, bld.Transition) + asrt.Equal(database.BuildReasonInitiator, bld.Reason) + asrt.Equal(buildID, bld.ID) + }), + withBuild, + expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) { + asrt.Equal(buildID, params.WorkspaceBuildID) + asrt.Empty(params.Name) + asrt.Empty(params.Value) + }), + + // Because no provisioners were available and the request was to delete --orphan + expectUpdateProvisionerJobWithCompleteWithStartedAtByID(func(params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) { + asrt.Equal(jobID, params.ID) + asrt.False(params.Error.Valid) + asrt.True(params.CompletedAt.Valid) + asrt.True(params.StartedAt.Valid) + }), + expectUpdateWorkspaceDeletedByID(func(params database.UpdateWorkspaceDeletedByIDParams) { + asrt.Equal(workspaceID, params.ID) + asrt.True(params.Deleted) + }), + expectGetProvisionerJobByID(func(job database.ProvisionerJob) { + asrt.Equal(jobID, job.ID) + }), + ) + + ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} + uut := wsbuilder.New(ws, database.WorkspaceTransitionDelete).Orphan() + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + req.NoError(err) + }) +} + func TestProvisionerVersionSupportsDynamicParameters(t *testing.T) { t.Parallel() @@ -1107,6 +1248,53 @@ func expectProvisionerJob( } } +// expectUpdateProvisionerJobWithCompleteWithStartedAtByID asserts a call to +// expectUpdateProvisionerJobWithCompleteWithStartedAtByID and runs the provided +// assertions against it. +func expectUpdateProvisionerJobWithCompleteWithStartedAtByID(assertions func(params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().UpdateProvisionerJobWithCompleteWithStartedAtByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) error { + assertions(params) + return nil + }, + ) + } +} + +// expectUpdateWorkspaceDeletedByID asserts a call to UpdateWorkspaceDeletedByID +// and runs the provided assertions against it. +func expectUpdateWorkspaceDeletedByID(assertions func(params database.UpdateWorkspaceDeletedByIDParams)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().UpdateWorkspaceDeletedByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, params database.UpdateWorkspaceDeletedByIDParams) error { + assertions(params) + return nil + }, + ) + } +} + +// expectGetProvisionerJobByID asserts a call to GetProvisionerJobByID +// and runs the provided assertions against it. +func expectGetProvisionerJobByID(assertions func(job database.ProvisionerJob)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().GetProvisionerJobByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, id uuid.UUID) (database.ProvisionerJob, error) { + job := database.ProvisionerJob{ID: id} + assertions(job) + return job, nil + }, + ) + } +} + func withBuild(mTx *dbmock.MockStore) { mTx.EXPECT().GetWorkspaceBuildByID(gomock.Any(), gomock.Any()).Times(1). DoAndReturn(func(ctx context.Context, id uuid.UUID) (database.WorkspaceBuild, error) { diff --git a/docs/manifest.json b/docs/manifest.json index c01eb828ac3a1..5fbb98f94b006 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1699,7 +1699,7 @@ }, { "title": "update", - "description": "Will update and start a given workspace if it is out of date", + "description": "Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first.", "path": "reference/cli/update.md" }, { diff --git a/docs/reference/cli/index.md b/docs/reference/cli/index.md index d72790fc3bfdb..6dae32c4c615c 100644 --- a/docs/reference/cli/index.md +++ b/docs/reference/cli/index.md @@ -22,50 +22,50 @@ Coder — A tool for provisioning self-hosted development environments with Terr ## Subcommands -| Name | Purpose | -|----------------------------------------------------|-------------------------------------------------------------------------------------------------------| -| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | -| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | -| [external-auth](./external-auth.md) | Manage external authentication | -| [login](./login.md) | Authenticate with Coder deployment | -| [logout](./logout.md) | Unauthenticate your local session | -| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | -| [notifications](./notifications.md) | Manage Coder notifications | -| [organizations](./organizations.md) | Organization related commands | -| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | -| [publickey](./publickey.md) | Output your Coder public key used for Git operations | -| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | -| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | -| [templates](./templates.md) | Manage templates | -| [tokens](./tokens.md) | Manage personal access tokens | -| [users](./users.md) | Manage users | -| [version](./version.md) | Show coder version | -| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | -| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | -| [create](./create.md) | Create a workspace | -| [delete](./delete.md) | Delete a workspace | -| [favorite](./favorite.md) | Add a workspace to your favorites | -| [list](./list.md) | List workspaces | -| [open](./open.md) | Open a workspace | -| [ping](./ping.md) | Ping a workspace | -| [rename](./rename.md) | Rename a workspace | -| [restart](./restart.md) | Restart a workspace | -| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | -| [show](./show.md) | Display details of a workspace's resources and agents | -| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | -| [ssh](./ssh.md) | Start a shell into a workspace or run a command | -| [start](./start.md) | Start a workspace | -| [stat](./stat.md) | Show resource usage for the current workspace. | -| [stop](./stop.md) | Stop a workspace | -| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | -| [update](./update.md) | Will update and start a given workspace if it is out of date | -| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | -| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | -| [server](./server.md) | Start a Coder server | -| [features](./features.md) | List Enterprise features | -| [licenses](./licenses.md) | Add, delete, and list licenses | -| [groups](./groups.md) | Manage groups | -| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | +| Name | Purpose | +|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------| +| [completion](./completion.md) | Install or update shell completion scripts for the detected or chosen shell. | +| [dotfiles](./dotfiles.md) | Personalize your workspace by applying a canonical dotfiles repository | +| [external-auth](./external-auth.md) | Manage external authentication | +| [login](./login.md) | Authenticate with Coder deployment | +| [logout](./logout.md) | Unauthenticate your local session | +| [netcheck](./netcheck.md) | Print network debug information for DERP and STUN | +| [notifications](./notifications.md) | Manage Coder notifications | +| [organizations](./organizations.md) | Organization related commands | +| [port-forward](./port-forward.md) | Forward ports from a workspace to the local machine. For reverse port forwarding, use "coder ssh -R". | +| [publickey](./publickey.md) | Output your Coder public key used for Git operations | +| [reset-password](./reset-password.md) | Directly connect to the database to reset a user's password | +| [state](./state.md) | Manually manage Terraform state to fix broken workspaces | +| [templates](./templates.md) | Manage templates | +| [tokens](./tokens.md) | Manage personal access tokens | +| [users](./users.md) | Manage users | +| [version](./version.md) | Show coder version | +| [autoupdate](./autoupdate.md) | Toggle auto-update policy for a workspace | +| [config-ssh](./config-ssh.md) | Add an SSH Host entry for your workspaces "ssh workspace.coder" | +| [create](./create.md) | Create a workspace | +| [delete](./delete.md) | Delete a workspace | +| [favorite](./favorite.md) | Add a workspace to your favorites | +| [list](./list.md) | List workspaces | +| [open](./open.md) | Open a workspace | +| [ping](./ping.md) | Ping a workspace | +| [rename](./rename.md) | Rename a workspace | +| [restart](./restart.md) | Restart a workspace | +| [schedule](./schedule.md) | Schedule automated start and stop times for workspaces | +| [show](./show.md) | Display details of a workspace's resources and agents | +| [speedtest](./speedtest.md) | Run upload and download tests from your machine to a workspace | +| [ssh](./ssh.md) | Start a shell into a workspace or run a command | +| [start](./start.md) | Start a workspace | +| [stat](./stat.md) | Show resource usage for the current workspace. | +| [stop](./stop.md) | Stop a workspace | +| [unfavorite](./unfavorite.md) | Remove a workspace from your favorites | +| [update](./update.md) | Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. | +| [whoami](./whoami.md) | Fetch authenticated user info for Coder deployment | +| [support](./support.md) | Commands for troubleshooting issues with a Coder deployment. | +| [server](./server.md) | Start a Coder server | +| [features](./features.md) | List Enterprise features | +| [licenses](./licenses.md) | Add, delete, and list licenses | +| [groups](./groups.md) | Manage groups | +| [provisioner](./provisioner.md) | View and manage provisioner daemons and jobs | ## Options diff --git a/docs/reference/cli/update.md b/docs/reference/cli/update.md index dd2bfa5ff76b5..35c5b34312420 100644 --- a/docs/reference/cli/update.md +++ b/docs/reference/cli/update.md @@ -1,7 +1,7 @@ # update -Will update and start a given workspace if it is out of date +Will update and start a given workspace if it is out of date. If the workspace is already running, it will be stopped first. ## Usage diff --git a/enterprise/cli/start_test.go b/enterprise/cli/start_test.go index b68a95ae1131d..2ef3b8cd801c4 100644 --- a/enterprise/cli/start_test.go +++ b/enterprise/cli/start_test.go @@ -9,7 +9,6 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" @@ -144,7 +143,7 @@ func TestStart(t *testing.T) { if cmd == "start" { // Stop the workspace so that we can start it. - coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) } // Start the workspace. Every test permutation should // pass. @@ -196,7 +195,7 @@ func TestStart(t *testing.T) { memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) workspace := coderdtest.CreateWorkspace(t, memberClient, template.ID) _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace.LatestBuild.ID) - _ = coderdtest.MustTransitionWorkspace(t, memberClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + _ = coderdtest.MustTransitionWorkspace(t, memberClient, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) err := memberClient.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ Dormant: true, }) diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index cfe5d081271e3..89671e00bd65c 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -171,6 +171,12 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { }) return } + // Skip membership checks for the prebuilds user. There is a valid use case + // for adding the prebuilds user to a single group: in order to set a quota + // allowance specifically for prebuilds. + if id == database.PrebuildsSystemUserID.String() { + continue + } _, err := database.ExpectOne(api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{ OrganizationID: group.OrganizationID, UserID: uuid.MustParse(id), diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index f87a9193f5fa4..568825adcd0ea 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -463,6 +463,32 @@ func TestPatchGroup(t *testing.T) { require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) + // For quotas to work with prebuilds, it's currently required to add the + // prebuilds user into a group with a quota allowance. + // See: docs/admin/templates/extending-templates/prebuilt-workspaces.md + t.Run("PrebuildsUser", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }}) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin()) + ctx := testutil.Context(t, testutil.WaitLong) + group, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "prebuilds", + QuotaAllowance: 123, + }) + require.NoError(t, err) + + group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + Name: "prebuilds", + AddUsers: []string{database.PrebuildsSystemUserID.String()}, + }) + require.NoError(t, err) + }) + t.Run("Everyone", func(t *testing.T) { t.Parallel() t.Run("NoUpdateName", func(t *testing.T) { diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index e8a29ccc33959..125647b099282 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -962,7 +962,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Stop the workspace so we can assert autobuild does nothing // if we breach our inactivity threshold. - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Simulate not having accessed the workspace in a while. ticker <- ws.LastUsedAt.Add(2 * inactiveTTL) @@ -1150,7 +1150,7 @@ func TestWorkspaceAutobuild(t *testing.T) { cwr.AutostartSchedule = ptr.Ref(sched.String()) }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Assert that autostart works when the workspace isn't dormant.. tickCh <- sched.Next(ws.LatestBuild.CreatedAt) @@ -1319,7 +1319,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Create a new version so that we can assert we don't update // to the latest by default. @@ -1360,7 +1360,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Reset the workspace to the stopped state so we can try // to autostart again. - coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { + coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { req.TemplateVersionID = ws.LatestBuild.TemplateVersionID }) @@ -1420,7 +1420,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) next := ws.LatestBuild.CreatedAt // For each day of the week (Monday-Sunday) @@ -1448,7 +1448,7 @@ func TestWorkspaceAutobuild(t *testing.T) { assert.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[ws.ID]) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) } // Ensure that there is a valid next start at and that is is after @@ -1511,7 +1511,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Our next start at should be Monday require.NotNil(t, ws.NextStartAt) @@ -1573,7 +1573,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // Check we have a 'NextStartAt' require.NotNil(t, ws.NextStartAt) @@ -2099,7 +2099,7 @@ func TestExecutorAutostartBlocked(t *testing.T) { ) // Given: workspace is stopped - workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) // When: the autobuild executor ticks into the future go func() { diff --git a/provisioner/terraform/resources.go b/provisioner/terraform/resources.go index 686a947f7fcaa..a46953f030dda 100644 --- a/provisioner/terraform/resources.go +++ b/provisioner/terraform/resources.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/awalterschulze/gographviz" + "github.com/google/uuid" tfjson "github.com/hashicorp/terraform-json" "github.com/mitchellh/mapstructure" "golang.org/x/xerrors" @@ -93,6 +94,7 @@ type agentDisplayAppsAttributes struct { // A mapping of attributes on the "coder_app" resource. type agentAppAttributes struct { + ID string `mapstructure:"id"` AgentID string `mapstructure:"agent_id"` // Slug is required in terraform, but to avoid breaking existing users we // will default to the resource name if it is not specified. @@ -522,7 +524,17 @@ func ConvertState(ctx context.Context, modules []*tfjson.StateModule, rawGraph s continue } + id := attrs.ID + if id == "" { + // This should never happen since the "id" attribute is set on creation: + // https://github.com/coder/terraform-provider-coder/blob/cfa101df4635e405e66094fa7779f9a89d92f400/provider/app.go#L37 + logger.Warn(ctx, "coder_app's id was unexpectedly empty", slog.F("name", attrs.Name)) + + id = uuid.NewString() + } + agent.Apps = append(agent.Apps, &proto.App{ + Id: id, Slug: attrs.Slug, DisplayName: attrs.DisplayName, Command: attrs.Command, diff --git a/provisioner/terraform/resources_test.go b/provisioner/terraform/resources_test.go index d21c0a9c573ff..56077eed9eee9 100644 --- a/provisioner/terraform/resources_test.go +++ b/provisioner/terraform/resources_test.go @@ -967,6 +967,9 @@ func TestConvertResources(t *testing.T) { if agent.GetInstanceId() != "" { agent.Auth = &proto.Agent_InstanceId{} } + for _, app := range agent.Apps { + app.Id = "" + } } } @@ -1037,6 +1040,9 @@ func TestConvertResources(t *testing.T) { if agent.GetInstanceId() != "" { agent.Auth = &proto.Agent_InstanceId{} } + for _, app := range agent.Apps { + app.Id = "" + } } } // Convert expectedNoMetadata and resources into a diff --git a/provisionerd/proto/version.go b/provisionerd/proto/version.go index d5ecba99030b3..3ac93d7497860 100644 --- a/provisionerd/proto/version.go +++ b/provisionerd/proto/version.go @@ -37,6 +37,7 @@ import "github.com/coder/coder/v2/apiversion" // - Add new field named `scheduling` to `Prebuild`, with fields for timezone // and schedule rules to define cron-based scaling of prebuilt workspace // instances based on time patterns. +// - Added new field named `id` to `App`, which transports the ID generated by the coder_app provider to be persisted. const ( CurrentMajor = 1 CurrentMinor = 7 diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index 81ca588efaf93..047458457fedd 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -2285,6 +2285,7 @@ type App struct { Hidden bool `protobuf:"varint,11,opt,name=hidden,proto3" json:"hidden,omitempty"` OpenIn AppOpenIn `protobuf:"varint,12,opt,name=open_in,json=openIn,proto3,enum=provisioner.AppOpenIn" json:"open_in,omitempty"` Group string `protobuf:"bytes,13,opt,name=group,proto3" json:"group,omitempty"` + Id string `protobuf:"bytes,14,opt,name=id,proto3" json:"id,omitempty"` // If nil, new UUID will be generated. } func (x *App) Reset() { @@ -2410,6 +2411,13 @@ func (x *App) GetGroup() string { return "" } +func (x *App) GetId() string { + if x != nil { + return x.Id + } + return "" +} + // Healthcheck represents configuration for checking for app readiness. type Healthcheck struct { state protoimpl.MessageState @@ -4474,7 +4482,7 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x50, 0x61, 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, - 0xaa, 0x03, 0x0a, 0x03, 0x41, 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, + 0xba, 0x03, 0x0a, 0x03, 0x41, 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, @@ -4500,7 +4508,8 @@ var file_provisionersdk_proto_provisioner_proto_rawDesc = []byte{ 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x16, 0x2e, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x65, 0x72, 0x2e, 0x41, 0x70, 0x70, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x52, 0x06, 0x6f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, - 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x22, 0x59, 0x0a, 0x0b, + 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x0e, 0x0a, 0x02, + 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index cd4eb4960eb11..12dd709aeba7f 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -266,6 +266,7 @@ message App { bool hidden = 11; AppOpenIn open_in = 12; string group = 13; + string id = 14; // If nil, new UUID will be generated. } // Healthcheck represents configuration for checking for app readiness. diff --git a/site/e2e/helpers.ts b/site/e2e/helpers.ts index cc91984ae592f..262567b7d7c96 100644 --- a/site/e2e/helpers.ts +++ b/site/e2e/helpers.ts @@ -1018,9 +1018,22 @@ export const updateWorkspace = async ( await fillParameters(page, richParameters, buildParameters); await page.getByRole("button", { name: /update parameters/i }).click(); + // Wait for the update button to detach. + await page.waitForSelector( + "button[data-testid='workspace-update-button']:enabled", + { state: "detached" }, + ); + // Wait for the workspace to be running again. await page.waitForSelector("text=Workspace status: Running", { state: "visible", }); + // Wait for the stop button to be enabled again + await page.waitForSelector( + "button[data-testid='workspace-stop-button']:enabled", + { + state: "visible", + }, + ); }; export const updateWorkspaceParameters = async ( diff --git a/site/e2e/provisionerGenerated.ts b/site/e2e/provisionerGenerated.ts index ee53f18d66d58..9b6a0b3109ef7 100644 --- a/site/e2e/provisionerGenerated.ts +++ b/site/e2e/provisionerGenerated.ts @@ -311,6 +311,8 @@ export interface App { hidden: boolean; openIn: AppOpenIn; group: string; + /** If nil, new UUID will be generated. */ + id: string; } /** Healthcheck represents configuration for checking for app readiness. */ @@ -1041,6 +1043,9 @@ export const App = { if (message.group !== "") { writer.uint32(106).string(message.group); } + if (message.id !== "") { + writer.uint32(114).string(message.id); + } return writer; }, }; diff --git a/site/e2e/tests/app.spec.ts b/site/e2e/tests/app.spec.ts index a12c1baccd735..587775b4dc3f8 100644 --- a/site/e2e/tests/app.spec.ts +++ b/site/e2e/tests/app.spec.ts @@ -42,6 +42,7 @@ test("app", async ({ context, page }) => { token, apps: [ { + id: randomUUID(), url: `http://localhost:${addr.port}`, displayName: appName, order: 0, diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index e72dd5f8d0bad..04536675f8943 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -1,5 +1,7 @@ import { + MockStoppedWorkspace, MockTemplate, + MockTemplateVersion2, MockTemplateVersionParameter1, MockTemplateVersionParameter2, MockWorkspace, @@ -171,65 +173,112 @@ describe("api.ts", () => { }); describe("update", () => { - it("creates a build with start and the latest template", async () => { - jest - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValueOnce(MockWorkspaceBuild); - jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); - await API.updateWorkspace(MockWorkspace); - expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { - transition: "start", - template_version_id: MockTemplate.active_version_id, - rich_parameter_values: [], + describe("given a running workspace", () => { + it("stops with current version before starting with the latest version", async () => { + jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }); + jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + template_version_id: MockTemplateVersion2.id, + transition: "start", + }); + jest.spyOn(API, "getTemplate").mockResolvedValueOnce({ + ...MockTemplate, + active_version_id: MockTemplateVersion2.id, + }); + await API.updateWorkspace(MockWorkspace); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "stop", + log_level: undefined, + }); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "start", + template_version_id: MockTemplateVersion2.id, + rich_parameter_values: [], + }); }); - }); - it("fails when having missing parameters", async () => { - jest - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValue(MockWorkspaceBuild); - jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); - jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]); - jest - .spyOn(API, "getTemplateVersionRichParameters") - .mockResolvedValue([ + it("fails when having missing parameters", async () => { + jest + .spyOn(API, "postWorkspaceBuild") + .mockResolvedValue(MockWorkspaceBuild); + jest.spyOn(API, "getTemplate").mockResolvedValue(MockTemplate); + jest.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]); + jest + .spyOn(API, "getTemplateVersionRichParameters") + .mockResolvedValue([ + MockTemplateVersionParameter1, + { ...MockTemplateVersionParameter2, mutable: false }, + ]); + + let error = new Error(); + try { + await API.updateWorkspace(MockWorkspace); + } catch (e) { + error = e as Error; + } + + expect(error).toBeInstanceOf(MissingBuildParameters); + // Verify if the correct missing parameters are being passed + expect((error as MissingBuildParameters).parameters).toEqual([ MockTemplateVersionParameter1, { ...MockTemplateVersionParameter2, mutable: false }, ]); + }); - let error = new Error(); - try { + it("creates a build with no parameters if it is already filled", async () => { + jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + transition: "stop", + }); + jest.spyOn(API, "postWorkspaceBuild").mockResolvedValueOnce({ + ...MockWorkspaceBuild, + template_version_id: MockTemplateVersion2.id, + transition: "start", + }); + jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); + jest + .spyOn(API, "getWorkspaceBuildParameters") + .mockResolvedValue([MockWorkspaceBuildParameter1]); + jest.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValue([ + { + ...MockTemplateVersionParameter1, + required: true, + mutable: false, + }, + ]); await API.updateWorkspace(MockWorkspace); - } catch (e) { - error = e as Error; - } - - expect(error).toBeInstanceOf(MissingBuildParameters); - // Verify if the correct missing parameters are being passed - expect((error as MissingBuildParameters).parameters).toEqual([ - MockTemplateVersionParameter1, - { ...MockTemplateVersionParameter2, mutable: false }, - ]); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "stop", + log_level: undefined, + }); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { + transition: "start", + template_version_id: MockTemplate.active_version_id, + rich_parameter_values: [], + }); + }); }); - - it("creates a build with the no parameters if it is already filled", async () => { - jest - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValueOnce(MockWorkspaceBuild); - jest.spyOn(API, "getTemplate").mockResolvedValueOnce(MockTemplate); - jest - .spyOn(API, "getWorkspaceBuildParameters") - .mockResolvedValue([MockWorkspaceBuildParameter1]); - jest - .spyOn(API, "getTemplateVersionRichParameters") - .mockResolvedValue([ - { ...MockTemplateVersionParameter1, required: true, mutable: false }, - ]); - await API.updateWorkspace(MockWorkspace); - expect(API.postWorkspaceBuild).toHaveBeenCalledWith(MockWorkspace.id, { - transition: "start", - template_version_id: MockTemplate.active_version_id, - rich_parameter_values: [], + describe("given a stopped workspace", () => { + it("creates a build with start and the latest template", async () => { + jest + .spyOn(API, "postWorkspaceBuild") + .mockResolvedValueOnce(MockWorkspaceBuild); + jest.spyOn(API, "getTemplate").mockResolvedValueOnce({ + ...MockTemplate, + active_version_id: MockTemplateVersion2.id, + }); + await API.updateWorkspace(MockStoppedWorkspace); + expect(API.postWorkspaceBuild).toHaveBeenCalledWith( + MockStoppedWorkspace.id, + { + transition: "start", + template_version_id: MockTemplateVersion2.id, + rich_parameter_values: [], + }, + ); }); }); }); diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5b7cde65fb2ce..094502939821b 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -2237,6 +2237,7 @@ class ApiMethods { * - Update the build parameters and check if there are missed parameters for * the newest version * - If there are missing parameters raise an error + * - Stop the workspace with the current template version if it is already running * - Create a build with the latest version and updated build parameters */ updateWorkspace = async ( @@ -2274,6 +2275,19 @@ class ApiMethods { throw new MissingBuildParameters(missingParameters, activeVersionId); } + // Stop the workspace if it is already running. + if (workspace.latest_build.status === "running") { + const stopBuild = await this.stopWorkspace(workspace.id); + const awaitedStopBuild = await this.waitForBuild(stopBuild); + // If the stop is canceled halfway through, we bail. + // This is the same behaviour as restartWorkspace. + if (awaitedStopBuild?.status === "canceled") { + return Promise.reject( + new Error("Workspace stop was canceled, not proceeding with update."), + ); + } + } + return this.postWorkspaceBuild(workspace.id, { transition: "start", template_version_id: activeVersionId, diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx index 8f5179b0b64da..2cfb74f2765c3 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx @@ -43,6 +43,18 @@ export const WorkspaceDeleteDialog: FC = ({ const hasError = !deletionConfirmed && userConfirmationText.length > 0; const displayErrorMessage = hasError && !isFocused; const inputColor = hasError ? "error" : "primary"; + // Orphaning is sort of a "last resort" that should really only + // be used under the following circumstances: + // a) Terraform is failing to apply while deleting, which + // usually means that builds are failing as well. + // b) No provisioner is available to delete the workspace, which will + // cause the job to remain in the "pending" state indefinitely. + // The assumption here is that an admin will cancel the job, in which + // case we want to allow them to perform an orphan-delete. + const canOrphan = + canDeleteFailedWorkspace && + (workspace.latest_build.status === "failed" || + workspace.latest_build.status === "canceled"); return ( = ({ "data-testid": "delete-dialog-name-confirmation", }} /> - { - // Orphaning is sort of a "last resort" that should really only - // be used if Terraform is failing to apply while deleting, which - // usually means that builds are failing as well. - canDeleteFailedWorkspace && - workspace.latest_build.status === "failed" && ( -
-
- { - setOrphanWorkspace(!orphanWorkspace); - }} - className="option" - name="orphan_resources" - checked={orphanWorkspace} - data-testid="orphan-checkbox" - /> -
-
-

Orphan Resources

- - As a Template Admin, you may skip resource cleanup to - delete a failed workspace. Resources such as volumes and - virtual machines will not be destroyed.  - - Learn more... - - -
-
- ) - } + {canOrphan && ( +
+
+ { + setOrphanWorkspace(!orphanWorkspace); + }} + className="option" + name="orphan_resources" + checked={orphanWorkspace} + data-testid="orphan-checkbox" + /> +
+
+

Orphan Resources

+ + As a Template Admin, you may skip resource cleanup to delete + a failed workspace. Resources such as volumes and virtual + machines will not be destroyed.  + + Learn more... + + +
+
+ )} } diff --git a/site/src/modules/workspaces/actions.ts b/site/src/modules/workspaces/actions.ts index 6a255e2cd2c88..f109c4d9ad1b9 100644 --- a/site/src/modules/workspaces/actions.ts +++ b/site/src/modules/workspaces/actions.ts @@ -6,16 +6,19 @@ import type { Workspace } from "api/typesGenerated"; const actionTypes = [ "start", "starting", - // Replaces start when an update is required. + // Appears beside start when an update is available. "updateAndStart", + // Replaces start when an update is required. + "updateAndStartRequireActiveVersion", "stop", "stopping", "restart", "restarting", - // Replaces restart when an update is required. + // Appears beside restart when an update is available. "updateAndRestart", + // Replaces restart when an update is required. + "updateAndRestartRequireActiveVersion", "deleting", - "update", "updating", "activate", "activating", @@ -74,10 +77,10 @@ export const abilitiesByWorkspaceStatus = ( const actions: ActionType[] = ["stop"]; if (workspace.template_require_active_version && workspace.outdated) { - actions.push("updateAndRestart"); + actions.push("updateAndRestartRequireActiveVersion"); } else { if (workspace.outdated) { - actions.unshift("update"); + actions.unshift("updateAndRestart"); } actions.push("restart"); } @@ -99,10 +102,10 @@ export const abilitiesByWorkspaceStatus = ( const actions: ActionType[] = []; if (workspace.template_require_active_version && workspace.outdated) { - actions.push("updateAndStart"); + actions.push("updateAndStartRequireActiveVersion"); } else { if (workspace.outdated) { - actions.unshift("update"); + actions.unshift("updateAndStart"); } actions.push("start"); } @@ -128,7 +131,7 @@ export const abilitiesByWorkspaceStatus = ( } if (workspace.outdated) { - actions.unshift("update"); + actions.unshift("updateAndStart"); } return { diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index b02e4473eb57f..e7fc2a4190116 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -19,21 +19,41 @@ export interface ActionButtonProps { handleAction: (buildParameters?: WorkspaceBuildParameter[]) => void; disabled?: boolean; tooltipText?: string; + isRunning?: boolean; + requireActiveVersion?: boolean; } export const UpdateButton: FC = ({ handleAction, loading, + isRunning, + requireActiveVersion, }) => { return ( - handleAction()} + - - {loading ? <>Updating… : <>Update…} - + handleAction()} + > + {requireActiveVersion ? : } + {loading ? ( + <>Updating… + ) : isRunning ? ( + <>Update and restart… + ) : ( + <>Update and start… + )} + + ); }; @@ -84,19 +104,6 @@ export const StartButton: FC = ({ ); }; -export const UpdateAndStartButton: FC = ({ - handleAction, -}) => { - return ( - - handleAction()}> - - Update and start… - - - ); -}; - export const StopButton: FC = ({ handleAction, loading, @@ -138,19 +145,6 @@ export const RestartButton: FC = ({ ); }; -export const UpdateAndRestartButton: FC = ({ - handleAction, -}) => { - return ( - - handleAction()}> - - Update and restart… - - - ); -}; - export const CancelButton: FC = ({ handleAction }) => { return ( handleAction()}> diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 708c90bac6d35..1c38caa14ec21 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -18,8 +18,6 @@ import { RestartButton, StartButton, StopButton, - UpdateAndRestartButton, - UpdateAndStartButton, UpdateButton, } from "./Buttons"; import { DebugButton } from "./DebugButton"; @@ -81,9 +79,34 @@ export const WorkspaceActions: FC = ({ // A mapping of button type to the corresponding React component const buttonMapping: Record = { - update: , - updateAndStart: , - updateAndRestart: , + updateAndStart: ( + + ), + updateAndStartRequireActiveVersion: ( + + ), + updateAndRestart: ( + + ), + updateAndRestartRequireActiveVersion: ( + + ), updating: , start: ( { } await user.click(screen.getByRole("button", { name: /bulk actions/i })); - const updateButton = await screen.findByText(/update/i); + const updateButton = await screen.findByTestId("bulk-action-update"); await user.click(updateButton); // One click: no running workspaces warning, no dormant workspaces warning. @@ -146,7 +146,7 @@ describe("WorkspacesPage", () => { } await user.click(screen.getByRole("button", { name: /bulk actions/i })); - const updateButton = await screen.findByText(/update/i); + const updateButton = await screen.findByTestId("bulk-action-update"); await user.click(updateButton); // Two clicks: 1 running workspace, no dormant workspaces warning. @@ -184,7 +184,7 @@ describe("WorkspacesPage", () => { } await user.click(screen.getByRole("button", { name: /bulk actions/i })); - const updateButton = await screen.findByText(/update/i); + const updateButton = await screen.findByTestId("bulk-action-update"); await user.click(updateButton); // Two clicks: no running workspaces warning, 1 dormant workspace. @@ -224,7 +224,7 @@ describe("WorkspacesPage", () => { } await user.click(screen.getByRole("button", { name: /bulk actions/i })); - const updateButton = await screen.findByText(/update/i); + const updateButton = await screen.findByTestId("bulk-action-update"); await user.click(updateButton); // Three clicks: 1 running workspace, 1 dormant workspace. diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index db9c72568150e..6563533bc43da 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -171,7 +171,11 @@ export const WorkspacesPageView: FC = ({ - Update… + {" "} + Update…