mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
fix: complete job and mark workspace as deleted when no provisioners are available (#18465)
Alternate fix for https://github.com/coder/coder/issues/18080 Modifies wsbuilder to complete the provisioner job and mark the workspace as deleted if it is clear that no provisioner will be able to pick up the delete build. This has a significant advantage of not deviating too much from the current semantics of `POST /api/v2/workspacebuilds`. https://github.com/coder/coder/pull/18460 ends up returning a 204 on orphan delete due to no build being created. Downside is that we have to duplicate some responsibilities of provisionerdserver in wsbuilder. There is a slight gotcha to this approach though: if you stop a provisioner and then immediately try to orphan-delete, the job will still be created because of the provisioner heartbeat interval. However you can cancel it and try again.
This commit is contained in:
@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -60,28 +61,35 @@ func TestDelete(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||||
owner := coderdtest.CreateFirstUser(t, client)
|
owner := coderdtest.CreateFirstUser(t, client)
|
||||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
|
||||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, nil)
|
||||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
|
||||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
template := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
|
||||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID)
|
||||||
inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
|
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{})
|
doneChan := make(chan struct{})
|
||||||
pty := ptytest.New(t).Attach(inv)
|
pty := ptytest.New(t).Attach(inv)
|
||||||
inv.Stderr = pty.Output()
|
inv.Stderr = pty.Output()
|
||||||
go func() {
|
go func() {
|
||||||
defer close(doneChan)
|
defer close(doneChan)
|
||||||
err := inv.Run()
|
err := inv.WithContext(ctx).Run()
|
||||||
// When running with the race detector on, we sometimes get an EOF.
|
// When running with the race detector on, we sometimes get an EOF.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
assert.ErrorIs(t, err, io.EOF)
|
assert.ErrorIs(t, err, io.EOF)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
pty.ExpectMatch("has been deleted")
|
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.
|
// Super orphaned, as the workspace doesn't even have a user.
|
||||||
|
@ -4497,7 +4497,8 @@ func (q *FakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.Provi
|
|||||||
defer q.mutex.RUnlock()
|
defer q.mutex.RUnlock()
|
||||||
|
|
||||||
if len(q.provisionerDaemons) == 0 {
|
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
|
// copy the data so that the caller can't manipulate any data inside dbmem
|
||||||
// after returning
|
// after returning
|
||||||
|
@ -3,6 +3,7 @@ package coderd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@ -433,20 +434,56 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow
|
||||||
if provisionerJob != nil {
|
if provisionerJob != nil {
|
||||||
|
queuePos.ProvisionerJob = *provisionerJob
|
||||||
|
queuePos.QueuePosition = 0
|
||||||
if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil {
|
if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil {
|
||||||
// Client probably doesn't care about this error, so just log it.
|
// 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))
|
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(
|
apiBuild, err := api.convertWorkspaceBuild(
|
||||||
*workspaceBuild,
|
*workspaceBuild,
|
||||||
workspace,
|
workspace,
|
||||||
database.GetProvisionerJobsByIDsWithQueuePositionRow{
|
queuePos,
|
||||||
ProvisionerJob: *provisionerJob,
|
|
||||||
QueuePosition: 0,
|
|
||||||
},
|
|
||||||
[]database.WorkspaceResource{},
|
[]database.WorkspaceResource{},
|
||||||
[]database.WorkspaceResourceMetadatum{},
|
[]database.WorkspaceResourceMetadatum{},
|
||||||
[]database.WorkspaceAgent{},
|
[]database.WorkspaceAgent{},
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package coderd_test
|
package coderd_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
@ -25,6 +26,7 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
|
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
"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/dbgen"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"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.Run("Orphan", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
|
||||||
first := coderdtest.CreateFirstUser(t, client)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
t.Run("WithoutDelete", func(t *testing.T) {
|
||||||
defer cancel()
|
t.Parallel()
|
||||||
|
client, store := coderdtest.NewWithDatabase(t, nil)
|
||||||
|
first := coderdtest.CreateFirstUser(t, client)
|
||||||
|
templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin())
|
||||||
|
|
||||||
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil)
|
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
|
||||||
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID)
|
OwnerID: templateAdminUser.ID,
|
||||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
OrganizationID: first.OrganizationID,
|
||||||
|
}).Do()
|
||||||
|
|
||||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
defer cancel()
|
||||||
|
|
||||||
// Providing both state and orphan fails.
|
// Trying to orphan without delete transition fails.
|
||||||
_, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
_, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||||
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
|
TemplateVersionID: r.TemplateVersion.ID,
|
||||||
Transition: codersdk.WorkspaceTransitionDelete,
|
Transition: codersdk.WorkspaceTransitionStart,
|
||||||
ProvisionerState: []byte(" "),
|
Orphan: true,
|
||||||
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())
|
||||||
})
|
})
|
||||||
require.Error(t, err)
|
|
||||||
cerr := coderdtest.SDKError(t, err)
|
|
||||||
require.Equal(t, http.StatusBadRequest, cerr.StatusCode())
|
|
||||||
|
|
||||||
// Regular orphan operation succeeds.
|
t.Run("WithState", func(t *testing.T) {
|
||||||
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
t.Parallel()
|
||||||
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
|
client, store := coderdtest.NewWithDatabase(t, nil)
|
||||||
Transition: codersdk.WorkspaceTransitionDelete,
|
first := coderdtest.CreateFirstUser(t, client)
|
||||||
Orphan: true,
|
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())
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
|
||||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
|
|
||||||
|
|
||||||
_, err = client.Workspace(ctx, workspace.ID)
|
t.Run("NoPermission", func(t *testing.T) {
|
||||||
require.Error(t, err)
|
t.Parallel()
|
||||||
require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode())
|
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())
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -464,6 +464,50 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object
|
|||||||
return BuildError{http.StatusInternalServerError, "get workspace build", err}
|
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
|
return nil
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -839,6 +839,147 @@ func TestWorkspaceBuildWithPreset(t *testing.T) {
|
|||||||
req.NoError(err)
|
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) {
|
func TestProvisionerVersionSupportsDynamicParameters(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func withBuild(mTx *dbmock.MockStore) {
|
||||||
mTx.EXPECT().GetWorkspaceBuildByID(gomock.Any(), gomock.Any()).Times(1).
|
mTx.EXPECT().GetWorkspaceBuildByID(gomock.Any(), gomock.Any()).Times(1).
|
||||||
DoAndReturn(func(ctx context.Context, id uuid.UUID) (database.WorkspaceBuild, error) {
|
DoAndReturn(func(ctx context.Context, id uuid.UUID) (database.WorkspaceBuild, error) {
|
||||||
|
@ -43,6 +43,18 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
|
|||||||
const hasError = !deletionConfirmed && userConfirmationText.length > 0;
|
const hasError = !deletionConfirmed && userConfirmationText.length > 0;
|
||||||
const displayErrorMessage = hasError && !isFocused;
|
const displayErrorMessage = hasError && !isFocused;
|
||||||
const inputColor = hasError ? "error" : "primary";
|
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 (
|
return (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@ -97,49 +109,41 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
|
|||||||
"data-testid": "delete-dialog-name-confirmation",
|
"data-testid": "delete-dialog-name-confirmation",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{
|
{canOrphan && (
|
||||||
// Orphaning is sort of a "last resort" that should really only
|
<div css={styles.orphanContainer}>
|
||||||
// be used if Terraform is failing to apply while deleting, which
|
<div css={{ flexDirection: "column" }}>
|
||||||
// usually means that builds are failing as well.
|
<Checkbox
|
||||||
canDeleteFailedWorkspace &&
|
id="orphan_resources"
|
||||||
workspace.latest_build.status === "failed" && (
|
size="small"
|
||||||
<div css={styles.orphanContainer}>
|
color="warning"
|
||||||
<div css={{ flexDirection: "column" }}>
|
onChange={() => {
|
||||||
<Checkbox
|
setOrphanWorkspace(!orphanWorkspace);
|
||||||
id="orphan_resources"
|
}}
|
||||||
size="small"
|
className="option"
|
||||||
color="warning"
|
name="orphan_resources"
|
||||||
onChange={() => {
|
checked={orphanWorkspace}
|
||||||
setOrphanWorkspace(!orphanWorkspace);
|
data-testid="orphan-checkbox"
|
||||||
}}
|
/>
|
||||||
className="option"
|
</div>
|
||||||
name="orphan_resources"
|
<div css={{ flexDirection: "column" }}>
|
||||||
checked={orphanWorkspace}
|
<p className="info">Orphan Resources</p>
|
||||||
data-testid="orphan-checkbox"
|
<span css={{ fontSize: 12, marginTop: 4, display: "block" }}>
|
||||||
/>
|
As a Template Admin, you may skip resource cleanup to delete
|
||||||
</div>
|
a failed workspace. Resources such as volumes and virtual
|
||||||
<div css={{ flexDirection: "column" }}>
|
machines will not be destroyed.
|
||||||
<p className="info">Orphan Resources</p>
|
<Link
|
||||||
<span
|
href={docs(
|
||||||
css={{ fontSize: 12, marginTop: 4, display: "block" }}
|
"/user-guides/workspace-management#workspace-resources",
|
||||||
>
|
)}
|
||||||
As a Template Admin, you may skip resource cleanup to
|
target="_blank"
|
||||||
delete a failed workspace. Resources such as volumes and
|
rel="noreferrer"
|
||||||
virtual machines will not be destroyed.
|
>
|
||||||
<Link
|
Learn more...
|
||||||
href={docs(
|
</Link>
|
||||||
"/user-guides/workspace-management#workspace-resources",
|
</span>
|
||||||
)}
|
</div>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noreferrer"
|
)}
|
||||||
>
|
|
||||||
Learn more...
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user