mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: run a terraform plan before creating workspaces with the given template parameters (#1732)
This commit is contained in:
@@ -207,6 +207,13 @@ func New(options *Options) *API {
|
||||
r.Get("/parameters", api.templateVersionParameters)
|
||||
r.Get("/resources", api.templateVersionResources)
|
||||
r.Get("/logs", api.templateVersionLogs)
|
||||
r.Route("/dry-run", func(r chi.Router) {
|
||||
r.Post("/", api.postTemplateVersionDryRun)
|
||||
r.Get("/{jobID}", api.templateVersionDryRun)
|
||||
r.Get("/{jobID}/resources", api.templateVersionDryRunResources)
|
||||
r.Get("/{jobID}/logs", api.templateVersionDryRunLogs)
|
||||
r.Patch("/{jobID}/cancel", api.patchTemplateVersionDryRunCancel)
|
||||
})
|
||||
})
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
r.Get("/first", api.firstUser)
|
||||
|
||||
@@ -96,6 +96,10 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
|
||||
require.NoError(t, err, "upload file")
|
||||
workspaceResources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
|
||||
require.NoError(t, err, "workspace resources")
|
||||
templateVersionDryRun, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
ParameterValues: []codersdk.CreateParameterRequest{},
|
||||
})
|
||||
require.NoError(t, err, "template version dry-run")
|
||||
|
||||
// Always fail auth from this point forward
|
||||
authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil)
|
||||
@@ -262,6 +266,27 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceTemplate.InOrg(template.OrganizationID).WithID(template.ID.String()),
|
||||
},
|
||||
"POST:/api/v2/templateversions/{templateversion}/dry-run": {
|
||||
// The first check is to read the template
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/resources": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
|
||||
},
|
||||
"GET:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/logs": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
|
||||
},
|
||||
"PATCH:/api/v2/templateversions/{templateversion}/dry-run/{templateversiondryrun}/cancel": {
|
||||
AssertAction: rbac.ActionRead,
|
||||
AssertObject: rbac.ResourceTemplate.InOrg(version.OrganizationID).WithID(template.ID.String()),
|
||||
},
|
||||
"GET:/api/v2/provisionerdaemons": {
|
||||
StatusCode: http.StatusOK,
|
||||
AssertObject: rbac.ResourceProvisionerDaemon.WithID(provisionerds[0].ID.String()),
|
||||
@@ -350,6 +375,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
|
||||
route = strings.ReplaceAll(route, "{hash}", file.Hash)
|
||||
route = strings.ReplaceAll(route, "{workspaceresource}", workspaceResources[0].ID.String())
|
||||
route = strings.ReplaceAll(route, "{templateversion}", version.ID.String())
|
||||
route = strings.ReplaceAll(route, "{templateversiondryrun}", templateVersionDryRun.ID.String())
|
||||
route = strings.ReplaceAll(route, "{templatename}", template.Name)
|
||||
// Only checking org scoped params here
|
||||
route = strings.ReplaceAll(route, "{scope}", string(organizationParam.Scope))
|
||||
|
||||
Generated
+2
-1
@@ -50,7 +50,8 @@ CREATE TYPE parameter_type_system AS ENUM (
|
||||
|
||||
CREATE TYPE provisioner_job_type AS ENUM (
|
||||
'template_version_import',
|
||||
'workspace_build'
|
||||
'workspace_build',
|
||||
'template_version_dry_run'
|
||||
);
|
||||
|
||||
CREATE TYPE provisioner_storage_method AS ENUM (
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- It's not possible to drop enum values from enum types, so the UP has "IF NOT
|
||||
-- EXISTS".
|
||||
|
||||
-- Delete all jobs that use the new enum value.
|
||||
DELETE FROM
|
||||
provisioner_jobs
|
||||
WHERE
|
||||
type = 'template_version_dry_run'
|
||||
;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE provisioner_job_type
|
||||
ADD VALUE IF NOT EXISTS 'template_version_dry_run';
|
||||
@@ -179,6 +179,7 @@ type ProvisionerJobType string
|
||||
const (
|
||||
ProvisionerJobTypeTemplateVersionImport ProvisionerJobType = "template_version_import"
|
||||
ProvisionerJobTypeWorkspaceBuild ProvisionerJobType = "workspace_build"
|
||||
ProvisionerJobTypeTemplateVersionDryRun ProvisionerJobType = "template_version_dry_run"
|
||||
)
|
||||
|
||||
func (e *ProvisionerJobType) Scan(src interface{}) error {
|
||||
|
||||
@@ -13,11 +13,12 @@ import (
|
||||
|
||||
// ComputeScope targets identifiers to pull parameters from.
|
||||
type ComputeScope struct {
|
||||
TemplateImportJobID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
TemplateID uuid.NullUUID
|
||||
WorkspaceID uuid.NullUUID
|
||||
TemplateImportJobID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
TemplateID uuid.NullUUID
|
||||
WorkspaceID uuid.NullUUID
|
||||
AdditionalParameterValues []database.ParameterValue
|
||||
}
|
||||
|
||||
type ComputeOptions struct {
|
||||
@@ -142,6 +143,14 @@ func Compute(ctx context.Context, db database.Store, scope ComputeScope, options
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, any additional parameter values declared in the input
|
||||
for _, v := range scope.AdditionalParameterValues {
|
||||
err = compute.injectSingle(v, false)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("inject single parameter value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
values := make([]ComputedValue, 0, len(compute.computedParameterByName))
|
||||
for _, value := range compute.computedParameterByName {
|
||||
values = append(values, value)
|
||||
|
||||
@@ -110,6 +110,13 @@ type workspaceProvisionJob struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// The input for a "template_version_dry_run" job.
|
||||
type templateVersionDryRunJob struct {
|
||||
TemplateVersionID uuid.UUID `json:"template_version_id"`
|
||||
WorkspaceName string `json:"workspace_name"`
|
||||
ParameterValues []database.ParameterValue `json:"parameter_values"`
|
||||
}
|
||||
|
||||
// Implementation of the provisioner daemon protobuf server.
|
||||
type provisionerdServer struct {
|
||||
AccessURL *url.URL
|
||||
@@ -219,18 +226,15 @@ func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("compute parameters: %s", err))
|
||||
}
|
||||
// Convert parameters to the protobuf type.
|
||||
protoParameters := make([]*sdkproto.ParameterValue, 0, len(parameters))
|
||||
for _, computedParameter := range parameters {
|
||||
converted, err := convertComputedParameterValue(computedParameter)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("convert parameter: %s", err))
|
||||
}
|
||||
protoParameters = append(protoParameters, converted)
|
||||
|
||||
// Convert types to their corresponding protobuf types.
|
||||
protoParameters, err := convertComputedParameterValues(parameters)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("convert computed parameters to protobuf: %s", err))
|
||||
}
|
||||
transition, err := convertWorkspaceTransition(workspaceBuild.Transition)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprint("convert workspace transition: %w", err))
|
||||
return nil, failJob(fmt.Sprintf("convert workspace transition: %s", err))
|
||||
}
|
||||
|
||||
protoJob.Type = &proto.AcquiredJob_WorkspaceBuild_{
|
||||
@@ -249,6 +253,46 @@ func (server *provisionerdServer) AcquireJob(ctx context.Context, _ *proto.Empty
|
||||
},
|
||||
},
|
||||
}
|
||||
case database.ProvisionerJobTypeTemplateVersionDryRun:
|
||||
var input templateVersionDryRunJob
|
||||
err = json.Unmarshal(job.Input, &input)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("unmarshal job input %q: %s", job.Input, err))
|
||||
}
|
||||
|
||||
templateVersion, err := server.Database.GetTemplateVersionByID(ctx, input.TemplateVersionID)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("get template version: %s", err))
|
||||
}
|
||||
|
||||
// Compute parameters for the dry-run to consume.
|
||||
parameters, err := parameter.Compute(ctx, server.Database, parameter.ComputeScope{
|
||||
TemplateImportJobID: templateVersion.JobID,
|
||||
OrganizationID: job.OrganizationID,
|
||||
TemplateID: templateVersion.TemplateID,
|
||||
UserID: user.ID,
|
||||
WorkspaceID: uuid.NullUUID{},
|
||||
AdditionalParameterValues: input.ParameterValues,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("compute parameters: %s", err))
|
||||
}
|
||||
|
||||
// Convert types to their corresponding protobuf types.
|
||||
protoParameters, err := convertComputedParameterValues(parameters)
|
||||
if err != nil {
|
||||
return nil, failJob(fmt.Sprintf("convert computed parameters to protobuf: %s", err))
|
||||
}
|
||||
|
||||
protoJob.Type = &proto.AcquiredJob_TemplateDryRun_{
|
||||
TemplateDryRun: &proto.AcquiredJob_TemplateDryRun{
|
||||
ParameterValues: protoParameters,
|
||||
Metadata: &sdkproto.Provision_Metadata{
|
||||
CoderUrl: server.AccessURL.String(),
|
||||
WorkspaceName: input.WorkspaceName,
|
||||
},
|
||||
},
|
||||
}
|
||||
case database.ProvisionerJobTypeTemplateVersionImport:
|
||||
protoJob.Type = &proto.AcquiredJob_TemplateImport_{
|
||||
TemplateImport: &proto.AcquiredJob_TemplateImport{
|
||||
@@ -603,6 +647,35 @@ func (server *provisionerdServer) CompleteJob(ctx context.Context, completed *pr
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("complete job: %w", err)
|
||||
}
|
||||
case *proto.CompletedJob_TemplateDryRun_:
|
||||
for _, resource := range jobType.TemplateDryRun.Resources {
|
||||
server.Logger.Info(ctx, "inserting template dry-run job resource",
|
||||
slog.F("job_id", job.ID.String()),
|
||||
slog.F("resource_name", resource.Name),
|
||||
slog.F("resource_type", resource.Type))
|
||||
|
||||
err = insertWorkspaceResource(ctx, server.Database, jobID, database.WorkspaceTransitionStart, resource)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("insert resource: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = server.Database.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{
|
||||
ID: jobID,
|
||||
UpdatedAt: database.Now(),
|
||||
CompletedAt: sql.NullTime{
|
||||
Time: database.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("update provisioner job: %w", err)
|
||||
}
|
||||
server.Logger.Debug(ctx, "marked template dry-run job as completed", slog.F("job_id", jobID))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("complete job: %w", err)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, xerrors.Errorf("unknown job type %q; ensure coderd and provisionerd versions match",
|
||||
reflect.TypeOf(completed.Type).String())
|
||||
@@ -733,6 +806,19 @@ func convertLogSource(logSource proto.LogSource) (database.LogSource, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func convertComputedParameterValues(parameters []parameter.ComputedValue) ([]*sdkproto.ParameterValue, error) {
|
||||
protoParameters := make([]*sdkproto.ParameterValue, len(parameters))
|
||||
for i, computedParameter := range parameters {
|
||||
converted, err := convertComputedParameterValue(computedParameter)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("convert parameter: %w", err)
|
||||
}
|
||||
protoParameters[i] = converted
|
||||
}
|
||||
|
||||
return protoParameters, nil
|
||||
}
|
||||
|
||||
func convertComputedParameterValue(param parameter.ComputedValue) (*sdkproto.ParameterValue, error) {
|
||||
var scheme sdkproto.ParameterDestination_Scheme
|
||||
switch param.DestinationScheme {
|
||||
|
||||
+219
-7
@@ -2,6 +2,7 @@ package coderd
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -163,6 +164,216 @@ func (api *API) templateVersionParameters(rw http.ResponseWriter, r *http.Reques
|
||||
httpapi.Write(rw, http.StatusOK, values)
|
||||
}
|
||||
|
||||
func (api *API) postTemplateVersionDryRun(rw http.ResponseWriter, r *http.Request) {
|
||||
apiKey := httpmw.APIKey(r)
|
||||
templateVersion := httpmw.TemplateVersionParam(r)
|
||||
if !api.Authorize(rw, r, rbac.ActionRead, templateVersion) {
|
||||
return
|
||||
}
|
||||
// We use the workspace RBAC check since we don't want to allow dry runs if
|
||||
// the user can't create workspaces.
|
||||
if !api.Authorize(rw, r, rbac.ActionCreate,
|
||||
rbac.ResourceWorkspace.InOrg(templateVersion.OrganizationID).WithOwner(apiKey.UserID.String())) {
|
||||
return
|
||||
}
|
||||
|
||||
var req codersdk.CreateTemplateVersionDryRunRequest
|
||||
if !httpapi.Read(rw, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
job, err := api.Database.GetProvisionerJobByID(r.Context(), templateVersion.JobID)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
||||
Message: fmt.Sprintf("get provisioner job: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !job.CompletedAt.Valid {
|
||||
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
|
||||
Message: "Template version import job hasn't completed!",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert parameters from request to parameters for the job
|
||||
parameterValues := make([]database.ParameterValue, len(req.ParameterValues))
|
||||
for i, v := range req.ParameterValues {
|
||||
parameterValues[i] = database.ParameterValue{
|
||||
ID: uuid.Nil,
|
||||
Scope: database.ParameterScopeWorkspace,
|
||||
ScopeID: uuid.Nil,
|
||||
Name: v.Name,
|
||||
SourceScheme: database.ParameterSourceSchemeData,
|
||||
SourceValue: v.SourceValue,
|
||||
DestinationScheme: database.ParameterDestinationSchemeProvisionerVariable,
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal template version dry-run job with the parameters from the
|
||||
// request.
|
||||
input, err := json.Marshal(templateVersionDryRunJob{
|
||||
TemplateVersionID: templateVersion.ID,
|
||||
WorkspaceName: req.WorkspaceName,
|
||||
ParameterValues: parameterValues,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
|
||||
Message: fmt.Sprintf("marshal new provisioner job: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a dry-run job
|
||||
jobID := uuid.New()
|
||||
provisionerJob, err := api.Database.InsertProvisionerJob(r.Context(), database.InsertProvisionerJobParams{
|
||||
ID: jobID,
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
OrganizationID: templateVersion.OrganizationID,
|
||||
InitiatorID: apiKey.UserID,
|
||||
Provisioner: job.Provisioner,
|
||||
StorageMethod: job.StorageMethod,
|
||||
StorageSource: job.StorageSource,
|
||||
Type: database.ProvisionerJobTypeTemplateVersionDryRun,
|
||||
Input: input,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
||||
Message: fmt.Sprintf("insert provisioner job: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusCreated, convertProvisionerJob(provisionerJob))
|
||||
}
|
||||
|
||||
func (api *API) templateVersionDryRun(rw http.ResponseWriter, r *http.Request) {
|
||||
job, ok := api.fetchTemplateVersionDryRunJob(rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusOK, convertProvisionerJob(job))
|
||||
}
|
||||
|
||||
func (api *API) templateVersionDryRunResources(rw http.ResponseWriter, r *http.Request) {
|
||||
job, ok := api.fetchTemplateVersionDryRunJob(rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
api.provisionerJobResources(rw, r, job)
|
||||
}
|
||||
|
||||
func (api *API) templateVersionDryRunLogs(rw http.ResponseWriter, r *http.Request) {
|
||||
job, ok := api.fetchTemplateVersionDryRunJob(rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
api.provisionerJobLogs(rw, r, job)
|
||||
}
|
||||
|
||||
func (api *API) patchTemplateVersionDryRunCancel(rw http.ResponseWriter, r *http.Request) {
|
||||
templateVersion := httpmw.TemplateVersionParam(r)
|
||||
|
||||
job, ok := api.fetchTemplateVersionDryRunJob(rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !api.Authorize(rw, r, rbac.ActionUpdate,
|
||||
rbac.ResourceWorkspace.InOrg(templateVersion.OrganizationID).WithOwner(job.InitiatorID.String())) {
|
||||
return
|
||||
}
|
||||
|
||||
if job.CompletedAt.Valid {
|
||||
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
|
||||
Message: "Job has already completed",
|
||||
})
|
||||
return
|
||||
}
|
||||
if job.CanceledAt.Valid {
|
||||
httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{
|
||||
Message: "Job has already been marked as canceled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := api.Database.UpdateProvisionerJobWithCancelByID(r.Context(), database.UpdateProvisionerJobWithCancelByIDParams{
|
||||
ID: job.ID,
|
||||
CanceledAt: sql.NullTime{
|
||||
Time: database.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
||||
Message: fmt.Sprintf("update provisioner job: %s", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(rw, http.StatusOK, httpapi.Response{
|
||||
Message: "Job has been marked as canceled",
|
||||
})
|
||||
}
|
||||
|
||||
func (api *API) fetchTemplateVersionDryRunJob(rw http.ResponseWriter, r *http.Request) (database.ProvisionerJob, bool) {
|
||||
var (
|
||||
templateVersion = httpmw.TemplateVersionParam(r)
|
||||
jobID = chi.URLParam(r, "jobID")
|
||||
)
|
||||
if !api.Authorize(rw, r, rbac.ActionRead, templateVersion) {
|
||||
return database.ProvisionerJob{}, false
|
||||
}
|
||||
|
||||
jobUUID, err := uuid.Parse(jobID)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
|
||||
Message: "Job ID must be a valid UUID",
|
||||
})
|
||||
return database.ProvisionerJob{}, false
|
||||
}
|
||||
|
||||
job, err := api.Database.GetProvisionerJobByID(r.Context(), jobUUID)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Forbidden(rw)
|
||||
return database.ProvisionerJob{}, false
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
||||
Message: fmt.Sprintf("get provisioner job by ID %q: %s", jobUUID.String(), err),
|
||||
})
|
||||
return database.ProvisionerJob{}, false
|
||||
}
|
||||
if job.Type != database.ProvisionerJobTypeTemplateVersionDryRun {
|
||||
httpapi.Forbidden(rw)
|
||||
return database.ProvisionerJob{}, false
|
||||
}
|
||||
// Do a workspace resource check since it's basically a workspace dry-run .
|
||||
if !api.Authorize(rw, r, rbac.ActionRead,
|
||||
rbac.ResourceWorkspace.InOrg(templateVersion.OrganizationID).WithOwner(job.InitiatorID.String())) {
|
||||
return database.ProvisionerJob{}, false
|
||||
}
|
||||
|
||||
// Verify that the template version is the one used in the request.
|
||||
var input templateVersionDryRunJob
|
||||
err = json.Unmarshal(job.Input, &input)
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
|
||||
Message: fmt.Sprintf("unmarshal job metadata: %s", err),
|
||||
})
|
||||
return database.ProvisionerJob{}, false
|
||||
}
|
||||
if input.TemplateVersionID != templateVersion.ID {
|
||||
httpapi.Forbidden(rw)
|
||||
return database.ProvisionerJob{}, false
|
||||
}
|
||||
|
||||
return job, true
|
||||
}
|
||||
|
||||
func (api *API) templateVersionsByTemplate(rw http.ResponseWriter, r *http.Request) {
|
||||
template := httpmw.TemplateParam(r)
|
||||
if !api.Authorize(rw, r, rbac.ActionRead, template) {
|
||||
@@ -463,12 +674,13 @@ func (api *API) templateVersionLogs(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func convertTemplateVersion(version database.TemplateVersion, job codersdk.ProvisionerJob) codersdk.TemplateVersion {
|
||||
return codersdk.TemplateVersion{
|
||||
ID: version.ID,
|
||||
TemplateID: &version.TemplateID.UUID,
|
||||
CreatedAt: version.CreatedAt,
|
||||
UpdatedAt: version.UpdatedAt,
|
||||
Name: version.Name,
|
||||
Job: job,
|
||||
Readme: version.Readme,
|
||||
ID: version.ID,
|
||||
TemplateID: &version.TemplateID.UUID,
|
||||
OrganizationID: version.OrganizationID,
|
||||
CreatedAt: version.CreatedAt,
|
||||
UpdatedAt: version.UpdatedAt,
|
||||
Name: version.Name,
|
||||
Job: job,
|
||||
Readme: version.Readme,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
@@ -448,6 +450,207 @@ func TestPatchActiveTemplateVersion(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestTemplateVersionDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
resource := &proto.Resource{
|
||||
Name: "cool-resource",
|
||||
Type: "cool_resource_type",
|
||||
}
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{APIRateLimit: -1, IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{resource},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
// Create template version dry-run
|
||||
after := time.Now()
|
||||
job, err := client.CreateTemplateVersionDryRun(ctx, version.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
ParameterValues: []codersdk.CreateParameterRequest{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fetch template version dry-run
|
||||
newJob, err := client.TemplateVersionDryRun(ctx, version.ID, job.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, job.ID, newJob.ID)
|
||||
|
||||
// Stream logs
|
||||
logs, err := client.TemplateVersionDryRunLogsAfter(ctx, version.ID, job.ID, after)
|
||||
require.NoError(t, err)
|
||||
|
||||
logsDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(logsDone)
|
||||
|
||||
logCount := 0
|
||||
for range logs {
|
||||
logCount++
|
||||
}
|
||||
assert.GreaterOrEqual(t, logCount, 1, "unexpected log count")
|
||||
}()
|
||||
|
||||
// Wait for the job to complete
|
||||
require.Eventually(t, func() bool {
|
||||
job, err := client.TemplateVersionDryRun(ctx, version.ID, job.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
return job.Status == codersdk.ProvisionerJobSucceeded
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
|
||||
<-logsDone
|
||||
|
||||
resources, err := client.TemplateVersionDryRunResources(ctx, version.ID, job.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resources, 1)
|
||||
require.Equal(t, resource.Name, resources[0].Name)
|
||||
require.Equal(t, resource.Type, resources[0].Type)
|
||||
})
|
||||
|
||||
t.Run("ImportNotFinished", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
// This import job will never finish
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
})
|
||||
|
||||
_, err := client.CreateTemplateVersionDryRun(context.Background(), version.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
ParameterValues: []codersdk.CreateParameterRequest{},
|
||||
})
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("Cancel", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
})
|
||||
forceCompleteTemplateVersionJob(t, api.Database, client, version)
|
||||
|
||||
// Create the dry-run
|
||||
job, err := client.CreateTemplateVersionDryRun(context.Background(), version.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
ParameterValues: []codersdk.CreateParameterRequest{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
job, err := client.TemplateVersionDryRun(context.Background(), version.ID, job.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Logf("Status: %s", job.Status)
|
||||
return job.Status == codersdk.ProvisionerJobRunning
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
|
||||
err = client.CancelTemplateVersionDryRun(context.Background(), version.ID, job.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
job, err := client.TemplateVersionDryRun(context.Background(), version.ID, job.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Logf("Status: %s", job.Status)
|
||||
return job.Status == codersdk.ProvisionerJobCanceled
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
})
|
||||
|
||||
t.Run("AlreadyCompleted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
|
||||
// Create the dry-run
|
||||
job, err := client.CreateTemplateVersionDryRun(context.Background(), version.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
ParameterValues: []codersdk.CreateParameterRequest{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
job, err := client.TemplateVersionDryRun(context.Background(), version.ID, job.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Logf("Status: %s", job.Status)
|
||||
return job.Status == codersdk.ProvisionerJobSucceeded
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
|
||||
err = client.CancelTemplateVersionDryRun(context.Background(), version.ID, job.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode())
|
||||
})
|
||||
|
||||
t.Run("AlreadyCanceled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerD: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Log{
|
||||
Log: &proto.Log{},
|
||||
},
|
||||
}},
|
||||
})
|
||||
forceCompleteTemplateVersionJob(t, api.Database, client, version)
|
||||
|
||||
// Create the dry-run
|
||||
job, err := client.CreateTemplateVersionDryRun(context.Background(), version.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
ParameterValues: []codersdk.CreateParameterRequest{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.CancelTemplateVersionDryRun(context.Background(), version.ID, job.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.CancelTemplateVersionDryRun(context.Background(), version.ID, job.ID)
|
||||
var apiErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestPaginatedTemplateVersions creates a list of template versions and paginate.
|
||||
func TestPaginatedTemplateVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -539,3 +742,23 @@ func TestPaginatedTemplateVersions(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func forceCompleteTemplateVersionJob(t *testing.T, db database.Store, client *codersdk.Client, version codersdk.TemplateVersion) {
|
||||
t.Helper()
|
||||
|
||||
// HACK: we need the template version job to be finished so the dry-run job
|
||||
// can be created. We do this by canceling the job and then marking it as
|
||||
// successful.
|
||||
err := client.CancelTemplateVersion(context.Background(), version.ID)
|
||||
require.NoError(t, err)
|
||||
err = db.UpdateProvisionerJobWithCompleteByID(context.Background(), database.UpdateProvisionerJobWithCompleteByIDParams{
|
||||
ID: version.Job.ID,
|
||||
UpdatedAt: time.Now(),
|
||||
CompletedAt: sql.NullTime{
|
||||
Time: time.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
Error: sql.NullString{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -538,7 +538,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) {
|
||||
})
|
||||
|
||||
if testCase.expectedError != "" {
|
||||
require.EqualError(t, err, testCase.expectedError, "unexpected error when setting workspace autostart schedule")
|
||||
require.ErrorContains(t, err, testCase.expectedError, "unexpected error when setting workspace autostart schedule")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user