Skip to content

fix!: do not create provisioner job for orphan delete #18460

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/cliui/provisionerjob.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import (
)

func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Client, build uuid.UUID) error {
if build == uuid.Nil {
return nil
}
return ProvisionerJob(ctx, writer, ProvisionerJobOptions{
Fetch: func() (codersdk.ProvisionerJob, error) {
build, err := client.WorkspaceBuild(ctx, build)
Expand Down
7 changes: 6 additions & 1 deletion cli/cliutil/provisionerwarn.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"io"
"strings"

"github.com/google/uuid"

"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
)
Expand All @@ -26,12 +28,15 @@ Details:

// WarnMatchedProvisioners warns the user if there are no provisioners that
// match the requested tags for a given provisioner job.
// If the job is not pending, it is ignored.
// If the job is not pending or its ID is nil, it is ignored.
func WarnMatchedProvisioners(w io.Writer, mp *codersdk.MatchedProvisioners, job codersdk.ProvisionerJob) {
if mp == nil {
// Nothing in the response, nothing to do here!
return
}
if job.ID == uuid.Nil {
return
}
if job.Status != codersdk.ProvisionerJobPending {
// Only warn if the job is pending.
return
Expand Down
11 changes: 11 additions & 0 deletions cli/cliutil/provisionerwarn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"strings"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/cli/cliutil"
Expand All @@ -19,13 +20,20 @@ func TestWarnMatchedProvisioners(t *testing.T) {
job codersdk.ProvisionerJob
expect string
}{
{
name: "empty",
mp: nil,
job: codersdk.ProvisionerJob{},
expect: "",
},
{
name: "no_match",
mp: &codersdk.MatchedProvisioners{
Count: 0,
Available: 0,
},
job: codersdk.ProvisionerJob{
ID: uuid.New(),
Status: codersdk.ProvisionerJobPending,
},
expect: `there are no provisioners that accept the required tags`,
Expand All @@ -37,6 +45,7 @@ func TestWarnMatchedProvisioners(t *testing.T) {
Available: 0,
},
job: codersdk.ProvisionerJob{
ID: uuid.New(),
Status: codersdk.ProvisionerJobPending,
},
expect: `Provisioners that accept the required tags have not responded for longer than expected`,
Expand All @@ -48,13 +57,15 @@ func TestWarnMatchedProvisioners(t *testing.T) {
Available: 1,
},
job: codersdk.ProvisionerJob{
ID: uuid.New(),
Status: codersdk.ProvisionerJobPending,
},
},
{
name: "not_pending",
mp: &codersdk.MatchedProvisioners{},
job: codersdk.ProvisionerJob{
ID: uuid.New(),
Status: codersdk.ProvisionerJobRunning,
},
},
Expand Down
33 changes: 23 additions & 10 deletions cli/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -49,30 +50,42 @@ func TestDelete(t *testing.T) {

t.Run("Orphan", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
client, closer := coderdtest.NewWithProvisionerCloser(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)

// Ensure the provisioner daemon is closed before running the test.
// This is to validate that the orphan deletion does not require
// a provisioner job.
require.NoError(t, closer.Close())

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.
Expand Down
58 changes: 33 additions & 25 deletions coderd/workspacebuilds.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
builder = builder.VersionID(createBuild.TemplateVersionID)
}

// Deleting with the --orphan flag means we will not attempt to create a
// provisioner job, and we will just mark the workspace as deleted.
if createBuild.Orphan {
if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Expand Down Expand Up @@ -430,29 +432,37 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
}
}

apiBuild, err := api.convertWorkspaceBuild(
*workspaceBuild,
workspace,
database.GetProvisionerJobsByIDsWithQueuePositionRow{
ProvisionerJob: *provisionerJob,
QueuePosition: 0,
},
[]database.WorkspaceResource{},
[]database.WorkspaceResourceMetadatum{},
[]database.WorkspaceAgent{},
[]database.WorkspaceApp{},
[]database.WorkspaceAppStatus{},
[]database.WorkspaceAgentScript{},
[]database.WorkspaceAgentLogSource{},
database.TemplateVersion{},
provisionerDaemons,
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspace build.",
Detail: err.Error(),
})
return
if workspaceBuild == nil {
// IETF suggests 204 No Content for successful DELETE requests
// This isn't an actual DELETE request, but it is a request to delete.
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
} else {
apiBuild, err := api.convertWorkspaceBuild(
*workspaceBuild,
workspace,
database.GetProvisionerJobsByIDsWithQueuePositionRow{
ProvisionerJob: *provisionerJob,
QueuePosition: 0,
},
[]database.WorkspaceResource{},
[]database.WorkspaceResourceMetadatum{},
[]database.WorkspaceAgent{},
[]database.WorkspaceApp{},
[]database.WorkspaceAppStatus{},
[]database.WorkspaceAgentScript{},
[]database.WorkspaceAgentLogSource{},
database.TemplateVersion{},
provisionerDaemons,
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspace build.",
Detail: err.Error(),
})
// No early return here as we still want to publish the workspace update.
} else {
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
}
}

// If this workspace build has a different template version ID to the previous build
Expand All @@ -478,8 +488,6 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
Kind: wspubsub.WorkspaceEventKindStateChange,
WorkspaceID: workspace.ID,
})

httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
}

func (api *API) notifyWorkspaceUpdated(
Expand Down
48 changes: 37 additions & 11 deletions coderd/workspacebuilds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,40 +373,66 @@ func TestWorkspaceBuildsProvisionerState(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
first := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin())
member, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID)

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)
version := coderdtest.CreateTemplateVersion(t, templateAdmin, first.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, templateAdmin, first.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)

workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, workspace.LatestBuild.ID)

// Providing both state and orphan fails.
// Trying to orphan without delete transition fails.
_, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
Transition: codersdk.WorkspaceTransitionStart,
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,
})
require.Error(t, err)
cerr := coderdtest.SDKError(t, err)
cerr = coderdtest.SDKError(t, err)
require.Equal(t, http.StatusBadRequest, cerr.StatusCode())

// Trying to orphan without being a template admin fails.
memberWorkspace := coderdtest.CreateWorkspace(t, member, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, member, memberWorkspace.LatestBuild.ID)
_, err = member.CreateWorkspaceBuild(ctx, memberWorkspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
Transition: codersdk.WorkspaceTransitionDelete,
Orphan: true,
})
require.Error(t, err)
cerr = coderdtest.SDKError(t, err)
require.Equal(t, http.StatusForbidden, cerr.StatusCode())
require.Contains(t, cerr.Message, "Only template managers may orphan")

// Regular orphan operation succeeds.
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
build, err := templateAdmin.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
Transition: codersdk.WorkspaceTransitionDelete,
Orphan: true,
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
require.Empty(t, build)

_, err = client.Workspace(ctx, workspace.ID)
require.Error(t, err)
ws, err := client.Workspace(ctx, workspace.ID)
require.Empty(t, ws)
require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode())
require.Error(t, err)
})
}

Expand Down
45 changes: 43 additions & 2 deletions coderd/wsbuilder/wsbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ type versionTarget struct {
// The zero value of this struct means to use state from the last build. If there is no last build, no state is
// provided (i.e. first build on a newly created workspace).
//
// setting orphan: true means not to send any state. This can be used to deleted orphaned workspaces
// setting orphan: true means not to not create any build job and immediately mark the workspace as deleted.
//
// setting explicit to a non-nil value means to use the provided state
//
Expand Down Expand Up @@ -335,6 +335,31 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object
b.reason = database.BuildReasonInitiator
}

// Deleting a workspace with orphan set does not require creating any
// workspace build or provisioner job. In fact, creating a workspace build
// job in this case would be counter-productive.
if b.state.orphan {
if b.trans != database.WorkspaceTransitionDelete {
// This may also be checked by the caller, but we check it here for the
// sake of completeness.
return nil, nil, nil, BuildError{
Status: http.StatusBadRequest,
Message: "Orphan can only be set when deleting a workspace",
Wrapped: xerrors.New("orphan can only be set when deleting a workspace"),
}
}
if err := b.store.UpdateWorkspaceDeletedByID(b.ctx, database.UpdateWorkspaceDeletedByIDParams{
ID: b.workspace.ID,
Deleted: true,
}); err != nil {
return nil, nil, nil, BuildError{
Status: http.StatusInternalServerError,
Message: "Internal error marking workspace as deleted",
}
}
return nil, nil, nil, nil
}

workspaceBuildID := uuid.New()
input, err := json.Marshal(provisionerdserver.WorkspaceProvisionJob{
WorkspaceBuildID: workspaceBuildID,
Expand Down Expand Up @@ -939,11 +964,27 @@ func (b *Builder) authorize(authFunc func(action policy.Action, object rbac.Obje

// If custom state, deny request since user could be corrupting or leaking
// cloud state.
if b.state.explicit != nil || b.state.orphan {
if b.state.explicit != nil {
if !authFunc(policy.ActionUpdate, template.RBACObject()) {
return BuildError{http.StatusForbidden, "Only template managers may provide custom state", xerrors.New("Only template managers may provide custom state")}
}
}
// Orphaning can only be done by template managers, since it may require
// manual cleanup of cloud resource.
if b.state.orphan {
if !authFunc(policy.ActionUpdate, template.RBACObject()) {
return BuildError{http.StatusForbidden, "Only template managers may orphan", xerrors.New("Only template managers may orphan")}
}
}

// Requesting both custom state and orphan is unclear, so deny it.
if b.state.explicit != nil && b.state.orphan {
return BuildError{
http.StatusBadRequest,
"Orphaning a workspace build with custom state is not allowed as the intent is unclear.",
xerrors.New("Orphaning a workspace build with custom state is not allowed as the intent is unclear."),
}
}

if b.logLevel != "" && !authFunc(policy.ActionRead, rbac.ResourceDeploymentConfig) {
return BuildError{
Expand Down
Loading
Loading