mirror of https://github.com/coder/coder.git
fix: only allow promoting successful template versions (#9998)
This commit is contained in:
parent
48ee80a559
commit
f001a57614
|
@ -760,21 +760,53 @@ func UpdateTemplateVersion(t *testing.T, client *codersdk.Client, organizationID
|
|||
return templateVersion
|
||||
}
|
||||
|
||||
// AwaitTemplateVersionJobCompleted awaits for an import job to reach completed status.
|
||||
// AwaitTemplateVersionJobRunning waits for the build to be picked up by a provisioner.
|
||||
func AwaitTemplateVersionJobRunning(t *testing.T, client *codersdk.Client, version uuid.UUID) codersdk.TemplateVersion {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("waiting for template version %s build job to start", version)
|
||||
var templateVersion codersdk.TemplateVersion
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
templateVersion, err = client.TemplateVersion(ctx, version)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
t.Logf("template version job status: %s", templateVersion.Job.Status)
|
||||
switch templateVersion.Job.Status {
|
||||
case codersdk.ProvisionerJobPending:
|
||||
return false
|
||||
case codersdk.ProvisionerJobRunning:
|
||||
return true
|
||||
default:
|
||||
t.FailNow()
|
||||
return false
|
||||
}
|
||||
}, testutil.WaitShort, testutil.IntervalFast, "make sure you set `IncludeProvisionerDaemon`!")
|
||||
t.Logf("template version %s job has started", version)
|
||||
return templateVersion
|
||||
}
|
||||
|
||||
// AwaitTemplateVersionJobCompleted waits for the build to be completed. This may result
|
||||
// from cancelation, an error, or from completing successfully.
|
||||
func AwaitTemplateVersionJobCompleted(t *testing.T, client *codersdk.Client, version uuid.UUID) codersdk.TemplateVersion {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
t.Logf("waiting for template version job %s", version)
|
||||
t.Logf("waiting for template version %s build job to complete", version)
|
||||
var templateVersion codersdk.TemplateVersion
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
templateVersion, err = client.TemplateVersion(ctx, version)
|
||||
t.Logf("template version job status: %s", templateVersion.Job.Status)
|
||||
return assert.NoError(t, err) && templateVersion.Job.CompletedAt != nil
|
||||
}, testutil.WaitLong, testutil.IntervalMedium)
|
||||
t.Logf("got template version job %s", version)
|
||||
}, testutil.WaitLong, testutil.IntervalMedium, "make sure you set `IncludeProvisionerDaemon`!")
|
||||
t.Logf("template version %s job has completed", version)
|
||||
return templateVersion
|
||||
}
|
||||
|
||||
|
|
|
@ -2110,7 +2110,7 @@ func TestTemplateInsights_RBAC(t *testing.T) {
|
|||
t.Run("AsOwner", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
client := coderdtest.New(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
|
@ -2134,7 +2134,7 @@ func TestTemplateInsights_RBAC(t *testing.T) {
|
|||
t.Run("AsTemplateAdmin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
client := coderdtest.New(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
@ -2160,7 +2160,7 @@ func TestTemplateInsights_RBAC(t *testing.T) {
|
|||
t.Run("AsRegularUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
client := coderdtest.New(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
regular, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
|
||||
|
@ -2239,7 +2239,7 @@ func TestGenericInsights_RBAC(t *testing.T) {
|
|||
t.Run("AsOwner", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
client := coderdtest.New(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
|
@ -2261,7 +2261,7 @@ func TestGenericInsights_RBAC(t *testing.T) {
|
|||
t.Run("AsTemplateAdmin", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
client := coderdtest.New(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
|
@ -2285,7 +2285,7 @@ func TestGenericInsights_RBAC(t *testing.T) {
|
|||
t.Run("AsRegularUser", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
client := coderdtest.New(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
regular, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
|
||||
|
|
|
@ -20,6 +20,7 @@ import (
|
|||
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
||||
"github.com/coder/coder/v2/coderd/externalauth"
|
||||
|
@ -248,7 +249,7 @@ func (api *API) templateVersionRichParameters(rw http.ResponseWriter, r *http.Re
|
|||
return
|
||||
}
|
||||
if !job.CompletedAt.Valid {
|
||||
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Job hasn't completed!",
|
||||
})
|
||||
return
|
||||
|
@ -383,7 +384,7 @@ func (api *API) templateVersionVariables(rw http.ResponseWriter, r *http.Request
|
|||
return
|
||||
}
|
||||
if !job.CompletedAt.Valid {
|
||||
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Job hasn't completed!",
|
||||
})
|
||||
return
|
||||
|
@ -1040,6 +1041,22 @@ func (api *API) patchActiveTemplateVersion(rw http.ResponseWriter, r *http.Reque
|
|||
})
|
||||
return
|
||||
}
|
||||
job, err := api.Database.GetProvisionerJobByID(ctx, version.JobID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching template version job status.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
jobStatus := db2sdk.ProvisionerJobStatus(job)
|
||||
if jobStatus != codersdk.ProvisionerJobSucceeded {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "Only versions that have been built successfully can be promoted.",
|
||||
Detail: fmt.Sprintf("Attempted to promote a version with a %s build", jobStatus),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = api.Database.InTx(func(store database.Store) error {
|
||||
err = store.UpdateTemplateActiveVersionByID(ctx, database.UpdateTemplateActiveVersionByIDParams{
|
||||
|
|
|
@ -241,7 +241,9 @@ func TestPatchCancelTemplateVersion(t *testing.T) {
|
|||
})
|
||||
t.Run("AlreadyCanceled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
|
@ -255,15 +257,7 @@ func TestPatchCancelTemplateVersion(t *testing.T) {
|
|||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
version, err = client.TemplateVersion(ctx, version.ID)
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
t.Logf("Status: %s", version.Job.Status)
|
||||
return version.Job.Status == codersdk.ProvisionerJobRunning
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
coderdtest.AwaitTemplateVersionJobRunning(t, client, version.ID)
|
||||
err := client.CancelTemplateVersion(ctx, version.ID)
|
||||
require.NoError(t, err)
|
||||
err = client.CancelTemplateVersion(ctx, version.ID)
|
||||
|
@ -280,7 +274,9 @@ func TestPatchCancelTemplateVersion(t *testing.T) {
|
|||
// Running -> Canceling is the best we can do for now.
|
||||
t.Run("Canceling", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
|
@ -557,13 +553,60 @@ func TestPatchActiveTemplateVersion(t *testing.T) {
|
|||
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("DoesNotBelong", func(t *testing.T) {
|
||||
t.Run("CanceledBuild", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
version = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, nil, template.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
err := client.CancelTemplateVersion(ctx, version.ID)
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
err = client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{
|
||||
ID: version.ID,
|
||||
})
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
require.Contains(t, apiErr.Detail, "canceled")
|
||||
})
|
||||
|
||||
t.Run("PendingBuild", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
version = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, nil, template.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
err := client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{
|
||||
ID: version.ID,
|
||||
})
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
|
||||
require.Contains(t, apiErr.Detail, "pending")
|
||||
})
|
||||
|
||||
t.Run("DoesNotBelong", 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)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
_ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -576,13 +619,18 @@ func TestPatchActiveTemplateVersion(t *testing.T) {
|
|||
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Found", func(t *testing.T) {
|
||||
t.Run("SuccessfulBuild", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
Auditor: auditor,
|
||||
})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
version = coderdtest.UpdateTemplateVersion(t, client, user.OrganizationID, nil, template.ID)
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
@ -592,8 +640,8 @@ func TestPatchActiveTemplateVersion(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, auditor.AuditLogs(), 5)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[4].Action)
|
||||
require.Len(t, auditor.AuditLogs(), 6)
|
||||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[5].Action)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -255,11 +255,11 @@ func TestWorkspaceAgent(t *testing.T) {
|
|||
req.TemplateID = template.ID
|
||||
})
|
||||
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
err = client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{
|
||||
ID: version.ID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
// Creating another workspace is just easier.
|
||||
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
|
|
@ -428,7 +428,6 @@ func TestPostWorkspacesByOrganization(t *testing.T) {
|
|||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
other, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleMember(), rbac.RoleOwner())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
|
|
|
@ -32,6 +32,8 @@ export const VersionRow: React.FC<VersionRowProps> = ({
|
|||
onClick: () => navigate(version.name),
|
||||
});
|
||||
|
||||
const jobStatus = version.job.status;
|
||||
|
||||
return (
|
||||
<TimelineEntry
|
||||
data-testid={`version-${version.id}`}
|
||||
|
@ -78,10 +80,20 @@ export const VersionRow: React.FC<VersionRowProps> = ({
|
|||
<Stack direction="row" alignItems="center" spacing={2}>
|
||||
{isActive && <Pill text="Active" type="success" />}
|
||||
{isLatest && <Pill text="Newest" type="info" />}
|
||||
{jobStatus === "pending" && (
|
||||
<Pill text={<>Pending…</>} type="warning" lightBorder />
|
||||
)}
|
||||
{jobStatus === "running" && (
|
||||
<Pill text={<>Building…</>} type="warning" lightBorder />
|
||||
)}
|
||||
{(jobStatus === "canceling" || jobStatus === "canceled") && (
|
||||
<Pill text="Canceled" type="neutral" lightBorder />
|
||||
)}
|
||||
{jobStatus === "failed" && <Pill text="Failed" type="error" />}
|
||||
{onPromoteClick && (
|
||||
<Button
|
||||
className={styles.promoteButton}
|
||||
disabled={isActive}
|
||||
disabled={isActive || jobStatus !== "succeeded"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import { action } from "@storybook/addon-actions";
|
||||
import { MockTemplateVersion } from "testHelpers/entities";
|
||||
import {
|
||||
MockCanceledProvisionerJob,
|
||||
MockCancelingProvisionerJob,
|
||||
MockFailedProvisionerJob,
|
||||
MockPendingProvisionerJob,
|
||||
MockRunningProvisionerJob,
|
||||
MockTemplateVersion,
|
||||
} from "testHelpers/entities";
|
||||
import { VersionsTable } from "./VersionsTable";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const meta: Meta<typeof VersionsTable> = {
|
||||
title: "components/VersionsTable",
|
||||
title: "pages/TemplatePage/VersionsTable",
|
||||
component: VersionsTable,
|
||||
};
|
||||
|
||||
|
@ -43,6 +50,51 @@ export const CanPromote: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const BuildStatuses: Story = {
|
||||
args: {
|
||||
activeVersionId: MockTemplateVersion.id,
|
||||
onPromoteClick: action("onPromoteClick"),
|
||||
versions: [
|
||||
{
|
||||
...MockTemplateVersion,
|
||||
id: "6",
|
||||
name: "test-version-6",
|
||||
created_at: "2022-05-18T18:39:01.382927298Z",
|
||||
job: MockCancelingProvisionerJob,
|
||||
},
|
||||
{
|
||||
...MockTemplateVersion,
|
||||
id: "5",
|
||||
name: "test-version-5",
|
||||
created_at: "2022-05-18T18:39:01.382927298Z",
|
||||
job: MockCanceledProvisionerJob,
|
||||
},
|
||||
{
|
||||
...MockTemplateVersion,
|
||||
id: "4",
|
||||
name: "test-version-4",
|
||||
created_at: "2022-05-18T18:39:01.382927298Z",
|
||||
job: MockRunningProvisionerJob,
|
||||
},
|
||||
{
|
||||
...MockTemplateVersion,
|
||||
id: "3",
|
||||
name: "test-version-3",
|
||||
created_at: "2022-05-18T18:39:01.382927298Z",
|
||||
job: MockPendingProvisionerJob,
|
||||
},
|
||||
{
|
||||
...MockTemplateVersion,
|
||||
id: "2",
|
||||
name: "test-version-2",
|
||||
created_at: "2022-05-18T18:39:01.382927298Z",
|
||||
job: MockFailedProvisionerJob,
|
||||
},
|
||||
MockTemplateVersion,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
versions: [],
|
||||
|
|
|
@ -5,7 +5,7 @@ import TableCell from "@mui/material/TableCell";
|
|||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import { Timeline } from "components/Timeline/Timeline";
|
||||
import { FC } from "react";
|
||||
import { type FC } from "react";
|
||||
import * as TypesGen from "api/typesGenerated";
|
||||
import { EmptyState } from "components/EmptyState/EmptyState";
|
||||
import { TableLoader } from "components/TableLoader/TableLoader";
|
||||
|
@ -24,13 +24,15 @@ export interface VersionsTableProps {
|
|||
versions?: TypesGen.TemplateVersion[];
|
||||
}
|
||||
|
||||
export const VersionsTable: FC<React.PropsWithChildren<VersionsTableProps>> = ({
|
||||
versions,
|
||||
onPromoteClick,
|
||||
activeVersionId,
|
||||
}) => {
|
||||
export const VersionsTable: FC<VersionsTableProps> = (props) => {
|
||||
const { versions, onPromoteClick, activeVersionId } = props;
|
||||
|
||||
const latestVersionId = versions?.reduce(
|
||||
(latestSoFar, against) => {
|
||||
if (against.job.status !== "succeeded") {
|
||||
return latestSoFar;
|
||||
}
|
||||
|
||||
if (!latestSoFar) {
|
||||
return against;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue