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:
Ammar Bandukwala 2022-09-06 12:07:00 -05:00 committed by GitHub
parent 209e011404
commit 4f0105ef7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 334 additions and 51 deletions

View File

@ -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"

View File

@ -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
}

View File

@ -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})

View File

@ -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"

View File

@ -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
}

View File

@ -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)

View File

@ -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,

View File

@ -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})

View File

@ -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.

View File

@ -170,6 +170,7 @@ export interface CreateWorkspaceBuildRequest {
readonly transition: WorkspaceTransition
readonly dry_run?: boolean
readonly state?: string
readonly orphan?: boolean
readonly parameter_values?: CreateParameterRequest[]
}

View File

@ -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",
}),
}))

View File

@ -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,

View File

@ -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,
}

View File

@ -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)} />}

View File

@ -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,
},
}))

View File

@ -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",