Skip to content
Draft
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
36 changes: 36 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,7 @@ func New(options *Options) *API {
r.Get("/connection", api.workspaceAgentConnection)
r.Get("/containers", api.workspaceAgentListContainers)
r.Get("/containers/watch", api.watchWorkspaceAgentContainers)
r.Delete("/containers/devcontainers/{devcontainer}", api.workspaceAgentDeleteDevcontainer)
r.Post("/containers/devcontainers/{devcontainer}/recreate", api.workspaceAgentRecreateDevcontainer)
r.Get("/coordinate", api.workspaceAgentClientCoordinate)

Expand Down
96 changes: 96 additions & 0 deletions coderd/workspaceagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,96 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req
httpapi.Write(ctx, rw, http.StatusOK, cts)
}

// @Summary Delete devcontainer for workspace agent
// @ID delete-devcontainer-for-workspace-agent
// @Security CoderSessionToken
// @Tags Agents
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
// @Param devcontainer path string true "Devcontainer ID"
// @Success 204
// @Router /workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer} [delete]
func (api *API) workspaceAgentDeleteDevcontainer(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceAgent := httpmw.WorkspaceAgentParam(r)
workspace := httpmw.WorkspaceParam(r)

if !api.Authorize(r, policy.ActionUpdate, workspace) {
httpapi.Forbidden(rw)
return
}

devcontainer := chi.URLParam(r, "devcontainer")
if devcontainer == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Devcontainer ID is required.",
Validations: []codersdk.ValidationError{
{Field: "devcontainer", Detail: "Devcontainer ID is required."},
},
})
return
}

apiAgent, err := db2sdk.WorkspaceAgent(
api.DERPMap(),
*api.TailnetCoordinator.Load(),
workspaceAgent,
nil,
nil,
nil,
api.AgentInactiveDisconnectTimeout,
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error reading workspace agent.",
Detail: err.Error(),
})
return
}
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected),
})
return
}

// If the agent is unreachable, the request will hang. Assume that if we
// don't get a response after 30s that the agent is unreachable.
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
defer dialCancel()
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, workspaceAgent.ID)
if err != nil {
httpapi.Write(dialCtx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error dialing workspace agent.",
Detail: err.Error(),
})
return
}
defer release()

if err = agentConn.DeleteDevcontainer(ctx, devcontainer); err != nil {
if errors.Is(err, context.Canceled) {
httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{
Message: "Failed to delete devcontainer from agent.",
Detail: "Request timed out.",
})
return
}
// If the agent returns a codersdk.Error, we can return that directly.
if cerr, ok := codersdk.AsError(err); ok {
httpapi.Write(ctx, rw, cerr.StatusCode(), cerr.Response)
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting devcontainer.",
Detail: err.Error(),
})
return
}

httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}

// @Summary Recreate devcontainer for workspace agent
// @ID recreate-devcontainer-for-workspace-agent
// @Security CoderSessionToken
Expand All @@ -1134,6 +1224,12 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req
func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceAgent := httpmw.WorkspaceAgentParam(r)
workspace := httpmw.WorkspaceParam(r)

if !api.Authorize(r, policy.ActionUpdate, workspace) {
httpapi.Forbidden(rw)
return
}

devcontainer := chi.URLParam(r, "devcontainer")
if devcontainer == "" {
Expand Down
151 changes: 151 additions & 0 deletions coderd/workspaceagents_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
Expand All @@ -17,6 +18,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"

"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
Expand Down Expand Up @@ -319,3 +321,152 @@ func TestWatchAgentContainers(t *testing.T) {
}
})
}

func TestWorkspaceAgentDeleteDevcontainer(t *testing.T) {
t.Parallel()

tests := []struct {
name string
agentConnected bool // Controls FirstConnectedAt/LastConnectedAt validity
agentConnError error // Error returned by fakeAgentProvider.AgentConn (nil = success)
deleteError error // Error returned by DeleteDevcontainer mock (nil = success)
expectedStatusCode int
}{
{
name: "OK",
agentConnected: true,
agentConnError: nil,
deleteError: nil,
expectedStatusCode: http.StatusNoContent,
},
{
name: "AgentNotConnected",
agentConnected: false,
expectedStatusCode: http.StatusBadRequest,
},
{
name: "DevcontainerNotFound",
agentConnected: true,
deleteError: func() error {
body, _ := json.Marshal(codersdk.Response{
Message: "Devcontainer not found.",
})
return codersdk.ReadBodyAsError(&http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewReader(body)),
Request: &http.Request{URL: &url.URL{}},
})
}(),
expectedStatusCode: http.StatusNotFound,
},
{
name: "AgentConnectionFailure",
agentConnected: true,
agentConnError: xerrors.New("connection failed"),
expectedStatusCode: http.StatusInternalServerError,
},
{
name: "InternalError",
agentConnected: true,
deleteError: xerrors.New("internal error"),
expectedStatusCode: http.StatusInternalServerError,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

var (
ctx = testutil.Context(t, testutil.WaitShort)
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd")

mCtrl = gomock.NewController(t)
mDB = dbmock.NewMockStore(mCtrl)
mCoordinator = tailnettest.NewMockCoordinator(mCtrl)

agentID = uuid.New()
resourceID = uuid.New()
jobID = uuid.New()
buildID = uuid.New()
workspaceID = uuid.New()
devcontainerID = uuid.NewString()

r = chi.NewMux()

api = API{
ctx: ctx,
Options: &Options{
AgentInactiveDisconnectTimeout: testutil.WaitShort,
Database: mDB,
Logger: logger,
DeploymentValues: &codersdk.DeploymentValues{},
TailnetCoordinator: tailnettest.NewFakeCoordinator(),
},
}
)

var tailnetCoordinator tailnet.Coordinator = mCoordinator
api.TailnetCoordinator.Store(&tailnetCoordinator)

// Setup agent provider based on test case.
if tc.agentConnected && tc.agentConnError == nil {
mAgentConn := agentconnmock.NewMockAgentConn(mCtrl)
mAgentConn.EXPECT().DeleteDevcontainer(gomock.Any(), devcontainerID).Return(tc.deleteError)
api.agentProvider = fakeAgentProvider{
agentConn: func(_ context.Context, _ uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) {
return mAgentConn, func() {}, nil
},
}
} else if tc.agentConnError != nil {
api.agentProvider = fakeAgentProvider{
agentConn: func(_ context.Context, _ uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) {
return nil, nil, tc.agentConnError
},
}
}

// Setup database mocks for ExtractWorkspaceAgentParam middleware.
mDB.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID).Return(database.WorkspaceAgent{
ID: agentID,
ResourceID: resourceID,
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
FirstConnectedAt: sql.NullTime{Valid: tc.agentConnected, Time: dbtime.Now()},
LastConnectedAt: sql.NullTime{Valid: tc.agentConnected, Time: dbtime.Now()},
}, nil)
mDB.EXPECT().GetWorkspaceResourceByID(gomock.Any(), resourceID).Return(database.WorkspaceResource{
ID: resourceID,
JobID: jobID,
}, nil)
mDB.EXPECT().GetProvisionerJobByID(gomock.Any(), jobID).Return(database.ProvisionerJob{
ID: jobID,
Type: database.ProvisionerJobTypeWorkspaceBuild,
}, nil)
mDB.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), jobID).Return(database.WorkspaceBuild{
WorkspaceID: workspaceID,
ID: buildID,
}, nil)

// Allow db2sdk.WorkspaceAgent to complete.
mCoordinator.EXPECT().Node(gomock.Any()).Return(nil)

// Mount the HTTP handler and create the test server.
r.With(httpmw.ExtractWorkspaceAgentParam(mDB)).
Delete("/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}", api.workspaceAgentDeleteDevcontainer)

srv := httptest.NewServer(r)
defer srv.Close()

// Send the DELETE request using the test server's client.
req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
fmt.Sprintf("%s/workspaceagents/%s/containers/devcontainers/%s", srv.URL, agentID, devcontainerID), nil)
require.NoError(t, err)

resp, err := srv.Client().Do(req)
require.NoError(t, err)
defer resp.Body.Close()

require.Equal(t, tc.expectedStatusCode, resp.StatusCode)
})
}
}
Loading
Loading