diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index 9f9e6cd8554a0..669f5989c878f 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -193,16 +193,6 @@ jobs: - name: Install node_modules run: ./scripts/yarn_install.sh - - name: Install Protoc - run: | - # protoc must be in lockstep with our dogfood Dockerfile - # or the version in the comments will differ. - set -x - cd dogfood - DOCKER_BUILDKIT=1 docker build . --target proto -t protoc - protoc_dir=/usr/local/bin/protoc - docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_dir - chmod +x $protoc_dir - uses: actions/setup-go@v3 with: go-version: "~1.19" @@ -235,6 +225,18 @@ jobs: - name: Install goimports run: go install golang.org/x/tools/cmd/goimports@latest + - name: Install Protoc + run: | + # protoc must be in lockstep with our dogfood Dockerfile + # or the version in the comments will differ. + set -x + cd dogfood + DOCKER_BUILDKIT=1 docker build . --target proto -t protoc + protoc_path=/usr/local/bin/protoc + docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path + chmod +x $protoc_path + protoc --version + - name: make gen run: "make --output-sync -j -B gen" diff --git a/cli/delete.go b/cli/delete.go index 37134880ce108..fd30fe95a1c1e 100644 --- a/cli/delete.go +++ b/cli/delete.go @@ -12,6 +12,7 @@ import ( // nolint func deleteWorkspace() *cobra.Command { + var orphan bool cmd := &cobra.Command{ Annotations: workspaceCommand, Use: "delete ", @@ -36,9 +37,21 @@ func deleteWorkspace() *cobra.Command { if err != nil { return err } + + var state []byte + + if orphan { + cliui.Warn( + cmd.ErrOrStderr(), + "Orphaning workspace requires template edit permission", + ) + } + before := time.Now() build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionDelete, + Transition: codersdk.WorkspaceTransitionDelete, + ProvisionerState: state, + Orphan: orphan, }) if err != nil { return err @@ -53,6 +66,10 @@ func deleteWorkspace() *cobra.Command { return nil }, } + cmd.Flags().BoolVar(&orphan, "orphan", false, + `Delete a workspace without deleting its resources. This can delete a +workspace in a broken state, but may also lead to unaccounted cloud resources.`, + ) cliui.AllowSkipPrompt(cmd) return cmd } diff --git a/cli/delete_test.go b/cli/delete_test.go index 5e866d245c237..499c90ef31234 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -43,6 +43,35 @@ func TestDelete(t *testing.T) { <-doneChan }) + t.Run("Orphan", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + cmd, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan") + + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + cmd.SetErr(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + // When running with the race detector on, we sometimes get an EOF. + if err != nil { + assert.ErrorIs(t, err, io.EOF) + } + }() + pty.ExpectMatch("Cleaning Up") + <-doneChan + }) + t.Run("DifferentUser", func(t *testing.T) { t.Parallel() adminClient := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) diff --git a/cli/portforward.go b/cli/portforward.go index 3b78fa8d11a65..ae5daeb9541bd 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -12,11 +12,12 @@ import ( "sync" "syscall" - "cdr.dev/slog" "github.com/pion/udp" "github.com/spf13/cobra" "golang.org/x/xerrors" + "cdr.dev/slog" + "github.com/coder/coder/agent" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a129d0667344c..f4f26dc05d642 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -13,6 +13,7 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "math/big" @@ -75,8 +76,10 @@ type Options struct { AutobuildStats chan<- executor.Stats // IncludeProvisionerDaemon when true means to start an in-memory provisionerD - IncludeProvisionerDaemon bool - APIBuilder func(*coderd.Options) *coderd.API + IncludeProvisionerDaemon bool + APIBuilder func(*coderd.Options) *coderd.API + MetricsCacheRefreshInterval time.Duration + AgentStatsRefreshInterval time.Duration } // New constructs a codersdk client connected to an in-memory API instance. @@ -235,8 +238,8 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c }, }, AutoImportTemplates: options.AutoImportTemplates, - MetricsCacheRefreshInterval: time.Millisecond * 100, - AgentStatsRefreshInterval: time.Millisecond * 100, + MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, + AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, }) t.Cleanup(func() { _ = coderAPI.Close() @@ -752,3 +755,10 @@ func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { type nopcloser struct{} func (nopcloser) Close() error { return nil } + +// SDKError coerces err into an SDK error. +func SDKError(t *testing.T, err error) *codersdk.Error { + var cerr *codersdk.Error + require.True(t, errors.As(err, &cerr)) + return cerr +} diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 2147d0fc4d6a2..51ab0f386f5c7 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -549,7 +549,9 @@ func TestTemplateDAUs(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + MetricsCacheRefreshInterval: time.Millisecond * 100, }) user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index ac4d3962e2fe9..764127dd47a23 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -318,6 +318,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { } createBuild.TemplateVersionID = latestBuild.TemplateVersionID } + templateVersion, err := api.Database.GetTemplateVersionByID(r.Context(), createBuild.TemplateVersionID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ @@ -336,6 +337,47 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { }) return } + + template, err := api.Database.GetTemplateByID(r.Context(), templateVersion.TemplateID.UUID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get template", + Detail: err.Error(), + }) + return + } + + var state []byte + // If custom state, deny request since user could be corrupting or leaking + // cloud state. + if createBuild.ProvisionerState != nil || createBuild.Orphan { + if !api.Authorize(r, rbac.ActionUpdate, template.RBACObject()) { + httpapi.Write(rw, http.StatusForbidden, codersdk.Response{ + Message: "Only template managers may provide custom state", + }) + return + } + state = createBuild.ProvisionerState + } + + if createBuild.Orphan { + if createBuild.Transition != codersdk.WorkspaceTransitionDelete { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Orphan is only permitted when deleting a workspace.", + Detail: err.Error(), + }) + return + } + + if createBuild.ProvisionerState != nil && createBuild.Orphan { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.", + }) + return + } + state = []byte{} + } + templateVersionJob, err := api.Database.GetProvisionerJobByID(r.Context(), templateVersion.JobID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ @@ -363,15 +405,6 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - template, err := api.Database.GetTemplateByID(r.Context(), templateVersion.TemplateID.UUID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching template job.", - Detail: err.Error(), - }) - return - } - // Store prior build number to compute new build number var priorBuildNum int32 priorHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) @@ -393,6 +426,10 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } + if state == nil { + state = priorHistory.ProvisionerState + } + var workspaceBuild database.WorkspaceBuild var provisionerJob database.ProvisionerJob // This must happen in a transaction to ensure history can be inserted, and @@ -457,10 +494,6 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("insert provisioner job: %w", err) } - state := createBuild.ProvisionerState - if len(state) == 0 { - state = priorHistory.ProvisionerState - } workspaceBuild, err = db.InsertWorkspaceBuild(r.Context(), database.InsertWorkspaceBuildParams{ ID: workspaceBuildID, diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index 70a97fa5e2bb8..5ded39bf91c87 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "errors" "fmt" "net/http" "strconv" @@ -230,6 +231,95 @@ func TestWorkspaceBuilds(t *testing.T) { }) } +func TestWorkspaceBuildsProvisionerState(t *testing.T) { + t.Parallel() + + t.Run("Permissions", 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.AwaitTemplateVersionJob(t, client, version.ID) + + workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + Transition: codersdk.WorkspaceTransitionDelete, + ProvisionerState: []byte(" "), + }) + require.Nil(t, err) + + coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) + + // A regular user on the very same template must not be able to modify the + // state. + regularUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + workspace = coderdtest.CreateWorkspace(t, regularUser, first.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, regularUser, workspace.LatestBuild.ID) + + _, err = regularUser.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + Transition: workspace.LatestBuild.Transition, + ProvisionerState: []byte(" "), + }) + require.Error(t, err) + + var cerr *codersdk.Error + require.True(t, errors.As(err, &cerr)) + + code := cerr.StatusCode() + require.Equal(t, http.StatusForbidden, code, "unexpected status %s", http.StatusText(code)) + }) + + 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.AwaitTemplateVersionJob(t, client, version.ID) + + workspace := coderdtest.CreateWorkspace(t, client, first.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // 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) + 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, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) + + _, err = client.Workspace(ctx, workspace.ID) + require.Error(t, err) + require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode()) + }) +} + func TestPatchCancelWorkspaceBuild(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a4ab1fd54dee9..7a4cac76380e0 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -39,6 +39,8 @@ type CreateWorkspaceBuildRequest struct { Transition WorkspaceTransition `json:"transition" validate:"oneof=create start stop delete,required"` DryRun bool `json:"dry_run,omitempty"` ProvisionerState []byte `json:"state,omitempty"` + // Orphan may be set for the Destroy transition. + Orphan bool `json:"orphan,omitempty"` // ParameterValues are optional. It will write params to the 'workspace' scope. // This will overwrite any existing parameters with the same name. // This will not delete old params not included in this list. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2c69d43b127d2..d5d33226c4156 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -170,6 +170,7 @@ export interface CreateWorkspaceBuildRequest { readonly transition: WorkspaceTransition readonly dry_run?: boolean readonly state?: string + readonly orphan?: boolean readonly parameter_values?: CreateParameterRequest[] } diff --git a/site/src/components/CodeExample/CodeExample.tsx b/site/src/components/CodeExample/CodeExample.tsx index 8cf51bb815911..869cde43f16da 100644 --- a/site/src/components/CodeExample/CodeExample.tsx +++ b/site/src/components/CodeExample/CodeExample.tsx @@ -1,4 +1,4 @@ -import { makeStyles } from "@material-ui/core/styles" +import { makeStyles, Theme } from "@material-ui/core/styles" import { FC } from "react" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { combineClasses } from "../../util/combineClasses" @@ -9,6 +9,7 @@ export interface CodeExampleProps { className?: string buttonClassName?: string tooltipTitle?: string + inline?: boolean } /** @@ -19,8 +20,9 @@ export const CodeExample: FC> = ({ className, buttonClassName, tooltipTitle, + inline, }) => { - const styles = useStyles() + const styles = useStyles({ inline: inline }) return (
@@ -34,35 +36,41 @@ export const CodeExample: FC> = ({ ) } -const useStyles = makeStyles((theme) => ({ - root: { - display: "flex", +interface styleProps { + inline?: boolean +} + +const useStyles = makeStyles((theme) => ({ + root: (props) => ({ + display: props.inline ? "inline-flex" : "flex", flexDirection: "row", alignItems: "center", - background: "hsl(223, 27%, 3%)", - border: `1px solid ${theme.palette.divider}`, + background: props.inline ? "rgb(0 0 0 / 30%)" : "hsl(223, 27%, 3%)", + border: props.inline ? undefined : `1px solid ${theme.palette.divider}`, color: theme.palette.primary.contrastText, fontFamily: MONOSPACE_FONT_FAMILY, fontSize: 14, borderRadius: theme.shape.borderRadius, - padding: theme.spacing(0.5), - }, - code: { + padding: props.inline ? "0px" : theme.spacing(0.5), + }), + code: (props) => ({ padding: ` - ${theme.spacing(0.5)}px + ${props.inline ? 0 : theme.spacing(0.5)}px ${theme.spacing(0.75)}px - ${theme.spacing(0.5)}px - ${theme.spacing(2)}px + ${props.inline ? 0 : theme.spacing(0.5)}px + ${props.inline ? theme.spacing(1) : theme.spacing(2)}px `, width: "100%", display: "flex", alignItems: "center", wordBreak: "break-all", - }, - button: { + }), + button: (props) => ({ border: 0, - minWidth: 42, - minHeight: 42, + minWidth: props.inline ? 30 : 42, + minHeight: props.inline ? 30 : 42, borderRadius: theme.shape.borderRadius, - }, + padding: props.inline ? theme.spacing(0.4) : undefined, + background: "transparent", + }), })) diff --git a/site/src/components/CopyButton/CopyButton.tsx b/site/src/components/CopyButton/CopyButton.tsx index d02f528cea26b..1b402d1de314a 100644 --- a/site/src/components/CopyButton/CopyButton.tsx +++ b/site/src/components/CopyButton/CopyButton.tsx @@ -86,7 +86,6 @@ export const CopyButton: React.FC> = ({ const useStyles = makeStyles((theme) => ({ copyButtonWrapper: { display: "flex", - marginLeft: theme.spacing(1), }, copyButton: { borderRadius: theme.shape.borderRadius, diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx index 24e47c97918ce..f5056361d9c4a 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.stories.tsx @@ -1,5 +1,9 @@ import { ComponentMeta, Story } from "@storybook/react" -import { MockWorkspaceBuild, MockWorkspaceBuildLogs } from "../../testHelpers/entities" +import { + MockFailedWorkspaceBuild, + MockWorkspaceBuild, + MockWorkspaceBuildLogs, +} from "../../testHelpers/entities" import { WorkspaceBuildPageView, WorkspaceBuildPageViewProps } from "./WorkspaceBuildPageView" export default { @@ -13,5 +17,10 @@ export const Example = Template.bind({}) Example.args = { build: MockWorkspaceBuild, logs: MockWorkspaceBuildLogs, - isWaitingForLogs: false, +} + +export const FailedDelete = Template.bind({}) +FailedDelete.args = { + build: MockFailedWorkspaceBuild("delete"), + logs: MockWorkspaceBuildLogs, } diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx index 874d11b3290a4..2851d3cd00793 100644 --- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx @@ -6,6 +6,7 @@ import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHea import { Stack } from "../../components/Stack/Stack" import { WorkspaceBuildLogs } from "../../components/WorkspaceBuildLogs/WorkspaceBuildLogs" import { WorkspaceBuildStats } from "../../components/WorkspaceBuildStats/WorkspaceBuildStats" +import { WorkspaceBuildStateError } from "./WorkspaceBuildStateError" const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => { return [...logs].sort( @@ -26,6 +27,9 @@ export const WorkspaceBuildPageView: FC = ({ logs, + {build && build.transition === "delete" && build.job.status === "failed" && ( + + )} {build && } {!logs && } {logs && } diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildStateError.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildStateError.tsx new file mode 100644 index 0000000000000..e75d71b2d5d03 --- /dev/null +++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildStateError.tsx @@ -0,0 +1,54 @@ +import { makeStyles } from "@material-ui/core/styles" +import { WorkspaceBuild } from "api/typesGenerated" +import { CodeExample } from "components/CodeExample/CodeExample" +import { Stack } from "components/Stack/Stack" + +const Language = { + stateMessage: "The workspace may have failed to delete due to a Terraform state mismatch.", +} + +export interface WorkspaceBuildStateErrorProps { + build: WorkspaceBuild +} + +export const WorkspaceBuildStateError: React.FC = ({ build }) => { + const styles = useStyles() + + const orphanCommand = `coder rm ${ + build.workspace_owner_name + "/" + build.workspace_name + } --orphan` + return ( + + + + + {Language.stateMessage} A template admin may run{" "} + to delete the workspace skipping resource + destruction. + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + background: theme.palette.warning.main, + padding: `${theme.spacing(2)}px`, + borderRadius: theme.shape.borderRadius, + gap: 0, + }, + flex: { + display: "flex", + }, + messageBox: { + justifyContent: "space-between", + }, + errorMessage: { + marginRight: `${theme.spacing(1)}px`, + }, + iconButton: { + padding: 0, + }, +})) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 66642bf2bcdd2..3e3023f5ed228 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -132,6 +132,7 @@ export const MockFailedProvisionerJob: TypesGen.ProvisionerJob = { ...MockProvisionerJob, status: "failed", } + export const MockCancelingProvisionerJob: TypesGen.ProvisionerJob = { ...MockProvisionerJob, status: "canceling", @@ -212,6 +213,27 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { reason: "initiator", } +export const MockFailedWorkspaceBuild = ( + transition: TypesGen.WorkspaceTransition = "start", +): TypesGen.WorkspaceBuild => ({ + build_number: 1, + created_at: "2022-05-17T17:39:01.382927298Z", + id: "1", + initiator_id: MockUser.id, + initiator_name: MockUser.username, + job: MockFailedProvisionerJob, + name: "a-workspace-build", + template_version_id: "", + transition: transition, + updated_at: "2022-05-17T17:39:01.382927298Z", + workspace_name: "test-workspace", + workspace_owner_id: MockUser.id, + workspace_owner_name: MockUser.username, + workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3", + deadline: "2022-05-17T23:39:00.00Z", + reason: "initiator", +}) + export const MockWorkspaceBuildStop: TypesGen.WorkspaceBuild = { ...MockWorkspaceBuild, id: "2",