mirror of https://github.com/coder/coder.git
feat: add orphan support (#3849)
* feat: add resource orphanage * feat: deny custom state in build for regular users * Minor protoc improvements
This commit is contained in:
parent
209e011404
commit
4f0105ef7e
|
@ -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"
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
// nolint
|
||||
func deleteWorkspace() *cobra.Command {
|
||||
var orphan bool
|
||||
cmd := &cobra.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "delete <workspace>",
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -170,6 +170,7 @@ export interface CreateWorkspaceBuildRequest {
|
|||
readonly transition: WorkspaceTransition
|
||||
readonly dry_run?: boolean
|
||||
readonly state?: string
|
||||
readonly orphan?: boolean
|
||||
readonly parameter_values?: CreateParameterRequest[]
|
||||
}
|
||||
|
||||
|
|
|
@ -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<React.PropsWithChildren<CodeExampleProps>> = ({
|
|||
className,
|
||||
buttonClassName,
|
||||
tooltipTitle,
|
||||
inline,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const styles = useStyles({ inline: inline })
|
||||
|
||||
return (
|
||||
<div className={combineClasses([styles.root, className])}>
|
||||
|
@ -34,35 +36,41 @@ export const CodeExample: FC<React.PropsWithChildren<CodeExampleProps>> = ({
|
|||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
interface styleProps {
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<Theme, styleProps>((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",
|
||||
}),
|
||||
}))
|
||||
|
|
|
@ -86,7 +86,6 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
|
|||
const useStyles = makeStyles((theme) => ({
|
||||
copyButtonWrapper: {
|
||||
display: "flex",
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
copyButton: {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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<WorkspaceBuildPageViewProps> = ({ logs,
|
|||
</PageHeader>
|
||||
|
||||
<Stack>
|
||||
{build && build.transition === "delete" && build.job.status === "failed" && (
|
||||
<WorkspaceBuildStateError build={build} />
|
||||
)}
|
||||
{build && <WorkspaceBuildStats build={build} />}
|
||||
{!logs && <Loader />}
|
||||
{logs && <WorkspaceBuildLogs logs={sortLogsByCreatedAt(logs)} />}
|
||||
|
|
|
@ -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<WorkspaceBuildStateErrorProps> = ({ build }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
const orphanCommand = `coder rm ${
|
||||
build.workspace_owner_name + "/" + build.workspace_name
|
||||
} --orphan`
|
||||
return (
|
||||
<Stack className={styles.root}>
|
||||
<Stack direction="row" alignItems="center" className={styles.messageBox}>
|
||||
<Stack direction="row" spacing={0}>
|
||||
<span className={styles.errorMessage}>
|
||||
{Language.stateMessage} A template admin may run{" "}
|
||||
<CodeExample inline code={orphanCommand} /> to delete the workspace skipping resource
|
||||
destruction.
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}))
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue